diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000000..15fcc617e2 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,971 @@ +version: 2.1 + +aliases: + keychain: &keychain + run: + name: Add cert to the keychain + command: | + security create-keychain -p mysecretpassword $KEYCHAIN + security default-keychain -s $KEYCHAIN + security unlock-keychain -p mysecretpassword $KEYCHAIN + security import certs/cert.p12 -k $KEYCHAIN -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple: -s -k mysecretpassword $KEYCHAIN + environment: + KEYCHAIN: redisinsight.keychain + import: &import + run: + name: User certutil to import certificate + command: certutil -p %WIN_CSC_KEY_PASSWORD% -importpfx certs\redislabs_win.pfx + shell: cmd.exe + sign: &sign + run: + name: Sign application + command: | + $filePath = $(Get-ChildItem release -Filter RedisInsight*.exe | % { $_.FullName }) + $filePathWithQuotes = '"{0}"' -f $filePath + & "C:\Program Files (x86)\Windows Kits\10\bin\10.0.19041.0\x86\signtool.exe" sign /a /sm /n "Redis Labs Inc." /fd sha256 /tr http://sha256timestamp.ws.symantec.com/sha256/timestamp /v $FilePathWithQuotes + shell: powershell.exe + scan: &scan + run: + name: Virustotal scan + command: &virusscan | + uploadUrl=$(curl -sq -XGET https://www.virustotal.com/api/v3/files/upload_url -H "x-apikey: $VIRUSTOTAL_API_KEY" | jq -r '.data') + uploadFile=$("/usr/bin/find" /tmp/release -name ${FILE_NAME}) + echo "File to upload: ${uploadFile}" + analysedId=$(curl -sq -XPOST "${uploadUrl}" -H "x-apikey: $VIRUSTOTAL_API_KEY" --form file=@"${uploadFile}" | jq -r '.data.id') + if [ $analysedId == "null" ]; then + echo 'Status is null, something went wrong'; exit 1; + fi + echo "export ANALYZED_ID=${analysedId}" >> $BASH_ENV + echo "Virustotal Analyzed id: ${analysedId}" + sleep 10 + shell: /bin/bash + validate: &validate + run: + name: Virustotal validate scan results + command: &virusValidate | + analyzeStatus=$(curl -sq -XGET https://www.virustotal.com/api/v3/analyses/${ANALYZED_ID} -H "x-apikey: $VIRUSTOTAL_API_KEY" | jq -r '.data.attributes.status') + if [ $analyzeStatus == "null" ]; then + echo 'Status is null, something went wrong'; exit 1; + fi + + currentOperation="50" + until [ "$currentOperation" == "0" ]; do + if [ "$analyzeStatus" == "completed" ] + then + echo "Current status: ${analyzeStatus}"; break; + else + echo "Current status: ${analyzeStatus}, retries left: ${currentOperation} "; + analyzeStatus=$(curl -sq -XGET https://www.virustotal.com/api/v3/analyses/${ANALYZED_ID} -H "x-apikey: $VIRUSTOTAL_API_KEY" | jq -r '.data.attributes.status'); + sleep 20; + currentOperation=$[$currentOperation - 1]; + fi + done + + analyzeStats=$(curl -sq -XGET https://www.virustotal.com/api/v3/analyses/${ANALYZED_ID} -H "x-apikey: $VIRUSTOTAL_API_KEY" | jq -r '.data.attributes.stats') + analazedHarmless=$(echo ${analyzeStats} | jq '.harmless') + analazedMalicious=$(echo ${analyzeStats} | jq '.malicious') + analazedSuspicious=$(echo ${analyzeStats} | jq '.suspicious') + + if [ "$analyzeStatus" != "completed" ]; then + echo 'Analyse is not completed'; exit 1; + fi + echo "Results:" + echo "analazedHarmless: ${analazedHarmless}, analazedMalicious: ${analazedMalicious}, analazedSuspicious: ${analazedSuspicious}" + + if [ "$analazedHarmless" != "0" ] || [ "$analazedMalicious" != "0" ] || [ "$analazedSuspicious" != "0" ]; then + echo 'Found dangers'; exit 1; + fi + + echo 'Passed'; + shell: /bin/bash + no_output_timeout: 15m + iTestsNames: &iTestsNames + - oss-st-5 # OSS Standalone v5 + - oss-st-5-pass # OSS Standalone v5 with admin pass required + - oss-st-6 # OSS Standalone v6 and all modules + #- mods-preview # OSS Standalone and all preview modules // todo: uncomment after broken image will be fixed + - oss-st-6-tls # OSS Standalone v6 with TLS enabled + - oss-st-6-tls-auth # OSS Standalone v6 with TLS auth required + - oss-clu # OSS Cluster + - oss-clu-tls # OSS Cluster with TLS enabled + - oss-sent # OSS Sentinel + - re-st # Redis Enterprise with Standalone inside + - re-clu # Redis Enterprise with Cluster inside + dev-filter: &devFilter + filters: + branches: + only: + - develop + stage-filter: &stageFilter + filters: + branches: + only: + - /^release.*/ + prod-filter: &prodFilter + filters: + branches: + only: + - master + ui-deps-cache-key: &uiDepsCacheKey + key: v1-ui-deps-{{ checksum "yarn.lock" }} + api-deps-cache-key: &apiDepsCacheKey + key: v1-ui-deps-{{ checksum "redisinsight/api/yarn.lock" }} + +orbs: + win: circleci/windows@2.4.0 + node: circleci/node@4.4.0 + aws: circleci/aws-cli@2.0.3 + +executors: + linux-executor: + machine: + image: ubuntu-2004:202010-01 + +jobs: + # Test jobs + unit-tests-ui: + docker: + - image: circleci/node:15.14.0 + steps: + - checkout + - restore_cache: + <<: *uiDepsCacheKey + - run: + name: UI PROD dependencies audit + command: | + FILENAME=ui.prod.deps.audit.json + yarn audit --groups dependencies --json > $FILENAME || true && + FILENAME=$FILENAME DEPS="UI prod" node .circleci/deps-audit-report.js && + curl -H "Content-type: application/json" --data @slack.$FILENAME -H "Authorization: Bearer ${SLACK_AUDIT_REPORT_KEY}" -X POST https://slack.com/api/chat.postMessage + - run: + name: UI DEV dependencies audit + command: | + FILENAME=ui.dev.deps.audit.json + yarn audit --groups devDependencies --json > $FILENAME || true && + FILENAME=$FILENAME DEPS="UI dev" node .circleci/deps-audit-report.js && + curl -H "Content-type: application/json" --data @slack.$FILENAME -H "Authorization: Bearer ${SLACK_AUDIT_REPORT_KEY}" -X POST https://slack.com/api/chat.postMessage + - run: + name: Code analysis + command: | + SKIP_POSTINSTALL=1 yarn install + + FILENAME=ui.lint.audit.json + WORKDIR="." + yarn lint:ui -f json -o $FILENAME || true && + FILENAME=$FILENAME WORKDIR=$WORKDIR TARGET="UI" node .circleci/lint-report.js && + curl -H "Content-type: application/json" --data @$WORKDIR/slack.$FILENAME -H "Authorization: Bearer ${SLACK_AUDIT_REPORT_KEY}" -X POST https://slack.com/api/chat.postMessage + + FILENAME=rest.lint.audit.json + yarn lint -f json -o $FILENAME || true && + FILENAME=$FILENAME WORKDIR=$WORKDIR TARGET="REST" node .circleci/lint-report.js && + curl -H "Content-type: application/json" --data @$WORKDIR/slack.$FILENAME -H "Authorization: Bearer ${SLACK_AUDIT_REPORT_KEY}" -X POST https://slack.com/api/chat.postMessage + - run: + name: Unit tests UI + command: | + yarn test:cov --ci + - save_cache: + <<: *uiDepsCacheKey + paths: + - ./node_modules + unit-tests-api: + docker: + - image: circleci/node:15.14.0 + steps: + - checkout + - restore_cache: + <<: *apiDepsCacheKey + - run: + name: API PROD dependencies scan + command: | + FILENAME=api.prod.deps.audit.json + yarn --cwd redisinsight/api audit --groups dependencies --json > $FILENAME || true && + FILENAME=$FILENAME DEPS="API prod" node .circleci/deps-audit-report.js && + curl -H "Content-type: application/json" --data @slack.$FILENAME -H "Authorization: Bearer ${SLACK_AUDIT_REPORT_KEY}" -X POST https://slack.com/api/chat.postMessage + - run: + name: API DEV dependencies scan + command: | + FILENAME=api.dev.deps.audit.json + yarn --cwd redisinsight/api audit --groups devDependencies --json > $FILENAME || true && + FILENAME=$FILENAME DEPS="API dev" node .circleci/deps-audit-report.js && + curl -H "Content-type: application/json" --data @slack.$FILENAME -H "Authorization: Bearer ${SLACK_AUDIT_REPORT_KEY}" -X POST https://slack.com/api/chat.postMessage + - run: + name: Code analysis + command: | + yarn --cwd redisinsight/api + + FILENAME=api.lint.audit.json + WORKDIR="./redisinsight/api" + yarn lint:api -f json -o $FILENAME || true && + FILENAME=$FILENAME WORKDIR=$WORKDIR TARGET="API" node .circleci/lint-report.js && + curl -H "Content-type: application/json" --data @$WORKDIR/slack.$FILENAME -H "Authorization: Bearer ${SLACK_AUDIT_REPORT_KEY}" -X POST https://slack.com/api/chat.postMessage + - run: + name: Unit tests API + command: | + yarn --cwd redisinsight/api/ test:cov --ci + - save_cache: + <<: *apiDepsCacheKey + paths: + - ./redisinsight/api/node_modules + integration-tests-run: + executor: linux-executor + parameters: + rte: + description: Redis Test Environment name + type: string + build: + description: Backend build to run tests over + type: enum + default: local + enum: ['local', 'docker', 'saas'] + report: + description: Send report for test run to slack + type: boolean + default: false + steps: + - checkout + - restore_cache: + <<: *apiDepsCacheKey + - when: + condition: + equal: [ 'docker', << parameters.build >> ] + steps: + - attach_workspace: + at: /tmp + - run: + name: Load built docker image from workspace + command: | + docker image load -i /tmp/docker-release/docker.tar + - run: + name: Run tests + command: | + ./redisinsight/api/test/test-runs/start-test-run.sh -r << parameters.rte >> -t << parameters.build >> + mkdir -p mkdir itest/coverages && mkdir -p itest/results + cp ./redisinsight/api/test/test-runs/coverage/test-run-result.json ./itest/results/<< parameters.rte >>.result.json + cp ./redisinsight/api/test/test-runs/coverage/test-run-result.xml ./itest/results/<< parameters.rte >>.result.xml + cp ./redisinsight/api/test/test-runs/coverage/test-run-coverage.json ./itest/coverages/<< parameters.rte >>.coverage.json + - when: + condition: + equal: [ true, << parameters.report >> ] + steps: + - run: + name: Send report + when: always + command: | + ITEST_NAME=<< parameters.rte >> node ./.circleci/itest-results.js + curl -H "Content-type: application/json" --data @itests.report.json -H "Authorization: Bearer $SLACK_TEST_REPORT_KEY" -X POST https://slack.com/api/chat.postMessage + - store_test_results: + path: ./itest/results + - persist_to_workspace: + root: . + paths: + - ./itest/results/<< parameters.rte >>.result.json + - ./itest/coverages/<< parameters.rte >>.coverage.json + integration-tests-coverage: + executor: linux-executor + steps: + - checkout + - attach_workspace: + at: /tmp + - run: + name: Calculate coverage across all tests runs + command: | + sudo mkdir -p /usr/src/app + sudo cp -a ./redisinsight/api/. /usr/src/app/ + sudo cp -R /tmp/itest/coverages /usr/src/app && sudo chmod 777 -R /usr/src/app + cd /usr/src/app && npx nyc report -t ./coverages -r text -r text-summary + e2e-tests: + executor: linux-executor + parameters: + build: + description: Backend build to run tests over + type: enum + default: local + enum: ['local', 'docker'] + report: + description: Send report for test run to slack + type: boolean + default: false + steps: + - checkout + - when: + condition: + equal: [ 'docker', << parameters.build >> ] + steps: + - attach_workspace: + at: /tmp + - run: + name: Load built docker image from workspace + command: | + docker image load -i /tmp/docker-release/docker.tar + - run: + name: Run tests + command: | + docker-compose -f tests/e2e/docker-compose.yml -f tests/e2e/docker.docker-compose.yml up --abort-on-container-exit + no_output_timeout: 5m + - when: + condition: + equal: [ 'local', << parameters.build >> ] + steps: + - run: + name: Run tests + command: | + docker-compose -f tests/e2e/docker-compose.yml up --abort-on-container-exit + no_output_timeout: 5m + - when: + condition: + equal: [ true, << parameters.report >> ] + steps: + - run: + name: Send report + when: always + command: | + node ./.circleci/e2e-results.js + curl -H "Content-type: application/json" --data @e2e.report.json -H "Authorization: Bearer $SLACK_TEST_REPORT_KEY" -X POST https://slack.com/api/chat.postMessage + + # Build jobs + setup-sign-certificates: + executor: linux-executor + steps: + - run: + name: Setup sign certificates + command: | + mkdir -p certs + echo "$CSC_P12_HEX" | xxd -r -p > certs/cert.p12 + echo "$WIN_CSC_PFX_HEX" | xxd -r -p > certs/redislabs_win.pfx + - persist_to_workspace: + root: . + paths: + - certs + setup-build: + parameters: + env: + description: Build environemtnt (stage || prod) + type: enum + default: stage + enum: [ 'dev', 'stage', 'prod' ] + docker: + - image: cibuilds/github:0.13 + steps: + - checkout + - run: + command: | + mkdir electron + + CURRENT_VERSION=$(jq -r ".version" redisinsight/package.json) + echo "Version: ${CURRENT_VERSION}" + + if [ << parameters.env >> == "prod" ]; then + echo "Build version: $CURRENT_VERSION" + cp ./redisinsight/package.json ./electron/package.json + exit 0 + fi + + if [ << parameters.env >> == "dev" ]; then + VERSION=$CURRENT_VERSION-dev-$CIRCLE_BUILD_NUM + echo "Build version: $VERSION" + echo $(jq ".version=\"$VERSION\"" redisinsight/package.json) > electron/package.json + exit 0 + fi + + CURRENT_RC_TAG=$(git tag --points-at $CIRCLE_SHA1 --sort=-v:refname -l "$CURRENT_VERSION"-rc* | head -1) + echo "Current RC tag: $CURRENT_RC_TAG" + + VERSION="$CURRENT_VERSION"-rc1 + if [[ "$CURRENT_RC_TAG" == "" ]] + then + LATEST_RC_TAG=$(git tag --sort=-refname -l "$CURRENT_VERSION"-rc* | head -1) + echo "Latest RC tag: $LATEST_RC_TAG" + + if [[ "$LATEST_RC_TAG" == "" ]] + then + echo "new version: $VERSION" + # ghr -t ${GH_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -prerelease -delete ${VERSION} + else + echo "Trying to get RC number from LATEST_RC_TAG: $LATEST_RC_TAG" + RC_NUMBER=$(echo "$LATEST_RC_TAG" | sed -r 's/.*[^0-9]+([0-9]*)$/\1/') + NEW_RC_NUMBER=$(("$RC_NUMBER" + 1)) + echo "Trying increase RC number: $RC_NUMBER -> $NEW_RC_NUMBER" + VERSION=$(echo "$LATEST_RC_TAG" | sed -e "s/$RC_NUMBER$/$NEW_RC_NUMBER/g") + # ghr -t ${GH_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -prerelease -delete ${VERSION} + fi + else + echo "rc4 CURRENT! $CURRENT_RC_TAG" + VERSION=$CURRENT_RC_TAG + fi + + echo "Build version: $VERSION" + echo $(jq ".version=\"$VERSION\"" redisinsight/package.json) > electron/package.json + - persist_to_workspace: + root: /root/project + paths: + - electron + linux: + docker: + - image: circleci/node:15.14.0 + resource_class: large + parameters: + env: + description: Build environment (stage || prod) + type: enum + default: stage + enum: ['stage', 'prod'] + steps: + - checkout + - attach_workspace: + at: . + - run: + command: | + cp ./electron/package.json ./redisinsight/ + - run: + name: install dependencies + command: | + yarn --cwd redisinsight/api/ install + yarn install + yarn build:statics + no_output_timeout: 15m + - run: + name: Build linux AppImage and deb + command: | + if [ << parameters.env >> == 'prod' ]; then + yarn package:prod + exit 0; + fi + + SEGMENT_WRITE_KEY=$SEGMENT_WRITE_KEY_STAGE yarn package:<< parameters.env >> + - persist_to_workspace: + root: . + paths: + - release/RedisInsight*.deb + - release/RedisInsight*.AppImage + - release/*-linux.yml + macosx: + macos: + xcode: 11.3.0 + parameters: + env: + description: Build environment (stage || prod) + type: enum + default: stage + enum: ['stage', 'prod'] + steps: + - checkout + - node/install: + node-version: '15.14.0' + - attach_workspace: + at: . + - run: + command: | + cp ./electron/package.json ./redisinsight/ + - run: + name: install dependencies + command: | + yarn install + yarn --cwd redisinsight/api/ install + yarn build:statics + no_output_timeout: 15m + - <<: *keychain + - run: + name: Build macos dmg + command: | + if [ << parameters.env >> == 'prod' ]; then + yarn package:prod + rm -rf release/mac + exit 0; + fi + + SEGMENT_WRITE_KEY=$SEGMENT_WRITE_KEY_STAGE yarn package:<< parameters.env >> + rm -rf release/mac + no_output_timeout: 15m + - persist_to_workspace: + root: . + paths: + - release/RedisInsight*.zip + - release/RedisInsight*.dmg + - release/RedisInsight*.dmg.blockmap + - release/*-mac.yml + windows: + executor: + name: win/default + parameters: + env: + description: Build environment (stage || prod) + type: enum + default: stage + enum: ['stage', 'prod'] + steps: + - checkout + - attach_workspace: + at: . + - run: + command: | + cp ./electron/package.json ./redisinsight/ + - run: + name: Build windows exe + command: | + choco install nodejs --version=15.14.0 + # set ALL_REDIS_COMMANDS=$(curl $ALL_REDIS_COMMANDS_RAW_URL) + yarn install + yarn --cwd redisinsight/api/ install + yarn build:statics:win + if [ << parameters.env >> == 'prod' ]; then + yarn package:prod + rm -rf release/win-unpacked + exit 0; + fi + + SEGMENT_WRITE_KEY=$SEGMENT_WRITE_KEY_STAGE yarn package:<< parameters.env >> + rm -rf release/win-unpacked + shell: bash.exe + no_output_timeout: 20m + - persist_to_workspace: + root: . + paths: + - release/RedisInsight*.exe + - release/RedisInsight*.exe.blockmap + - release/*.yml + virustotal: + executor: linux-executor + parameters: + ext: + description: File extension + type: string + steps: + - checkout + - attach_workspace: + at: /tmp/release + - run: + name: export FILE_NAME environment variable + command: | + echo 'export FILE_NAME="RedisInsight*.<< parameters.ext >>"' >> $BASH_ENV + - <<: *scan + - <<: *validate + docker: + executor: linux-executor + parameters: + env: + type: enum + default: staging + enum: ['staging', 'production'] + steps: + - checkout + - run: + name: Build Docker image (API + UI) + command: | + docker build --build-arg NODE_ENV=<< parameters.env >> --build-arg SERVER_TLS_CERT="$SERVER_TLS_CERT" --build-arg SERVER_TLS_KEY="$SERVER_TLS_KEY" -t riv2:latest . + mkdir -p docker-release + docker image save -o docker-release/docker.tar riv2 + - persist_to_workspace: + root: . + paths: + - ./docker-release + + # Release jobs + store-build-artifacts: + executor: linux-executor + steps: + - attach_workspace: + at: . + - store_artifacts: + path: release + destination: release + release-github: + parameters: + env: + description: Release environment (stage || prod) + type: enum + default: stage + enum: [ 'stage', 'prod' ] + docker: + - image: cibuilds/github:0.13 + steps: + - checkout + - attach_workspace: + at: . + - store_artifacts: + path: release + destination: release + - run: + name: prepare release + command: | + rm release/._* ||: + - run: + name: publish to prerelease Github + command: | + applicationVersion=$(jq -r '.version' electron/package.json) + echo "APP VERSION $applicationVersion" + ghr -t ${GH_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -prerelease -delete ${applicationVersion} + + release-aws-private: + executor: linux-executor + steps: + - checkout + - attach_workspace: + at: . + - store_artifacts: + path: release + destination: release + - run: + name: prepare release + command: | + rm release/._* ||: + - run: + name: publish + command: | + applicationVersion=$(jq -r '.version' redisinsight/package.json) + + aws s3 cp release/ s3://${AWS_BUCKET_NAME}/private/${applicationVersion} --recursive --exclude "*.json" + + publish-prod-aws: + executor: linux-executor + steps: + - checkout + - run: + name: Init variables + command: | + latestYmlFileName="latest.yml" + downloadLatestFolderPath="public/latest" + upgradeLatestFolderPath="public/upgrades" + appName=$(jq -r '.productName' electron-builder.json) + appVersion=$(jq -r '.version' redisinsight/package.json) + + echo "export downloadLatestFolderPath=${downloadLatestFolderPath}" >> $BASH_ENV + echo "export upgradeLatestFolderPath=${upgradeLatestFolderPath}" >> $BASH_ENV + echo "export applicationName=${appName}" >> $BASH_ENV + echo "export applicationVersion=${appVersion}" >> $BASH_ENV + echo "export appFileName=RedisInsight" >> $BASH_ENV + + # download latest.yml file to get last public version + aws s3 cp s3://${AWS_BUCKET_NAME}/${downloadLatestFolderPath}/${latestYmlFileName} . + + versionLine=$(head -1 ${latestYmlFileName}) + versionLineArr=(${versionLine/:// }) + previousAppVersion=${versionLineArr[1]} + + echo "export previousApplicationVersion=${previousAppVersion}" >> $BASH_ENV + + - run: + name: Publish AWS S3 + command: | + # move last public version apps for download to /private/{last public version} + aws s3 mv s3://${AWS_BUCKET_NAME}/${downloadLatestFolderPath} \ + s3://${AWS_BUCKET_NAME}/private/${previousApplicationVersion}/ --recursive + + # move last public version apps for upgrades to /private/{last public version} + aws s3 mv s3://${AWS_BUCKET_NAME}/${upgradeLatestFolderPath} \ + s3://${AWS_BUCKET_NAME}/private/${previousApplicationVersion}/ --recursive + + # move current version apps for download to /public/latest + aws s3 cp s3://${AWS_BUCKET_NAME}/private/${applicationVersion}/ \ + s3://${AWS_BUCKET_NAME}/${downloadLatestFolderPath} --recursive --exclude "*.zip" + + # copy current version apps for upgrades to /public/upgrades + aws s3 mv s3://${AWS_BUCKET_NAME}/private/${applicationVersion}/ \ + s3://${AWS_BUCKET_NAME}/${upgradeLatestFolderPath} --recursive + + - run: + name: Add tags for all objects and create S3 metrics + command: | + + # declare all tags + declare -A tag0=( + [key]='platform' + [value]='macos' + [objectDownload]=${appFileName}'-mac-x64.dmg' + [objectUpgrade]=${appFileName}'.zip' + ) + + declare -A tag1=( + [key]='platform' + [value]='windows' + [objectDownload]=${appFileName}'-win-installer.exe' + ) + + declare -A tag2=( + [key]='platform' + [value]='linux_AppImage' + [objectDownload]=${appFileName}'-linux.AppImage' + ) + + declare -A tag3=( + [key]='platform' + [value]='linux_deb' + [objectDownload]=${appFileName}'-linux.deb' + ) + + # loop for add all tags to each app and create metrics + declare -n tag + for tag in ${!tag@}; do + + designation0="downloads" + designation1="upgrades" + + id0="${tag[value]}_${designation0}_${applicationVersion}" + id1="${tag[value]}_${designation1}_${applicationVersion}" + + # add tags to each app for download + aws s3api put-object-tagging \ + --bucket ${AWS_BUCKET_NAME} \ + --key ${downloadLatestFolderPath}/${tag[objectDownload]} \ + --tagging '{"TagSet": [{ "Key": "version", "Value": "'"${applicationVersion}"'" }, {"Key": "'"${tag[key]}"'", "Value": "'"${tag[value]}"'"}, { "Key": "designation", "Value": "'"${designation0}"'" }]}' + + # add tags to each app for upgrades + aws s3api put-object-tagging \ + --bucket ${AWS_BUCKET_NAME} \ + --key ${upgradeLatestFolderPath}/${tag[objectUpgrade]:=${tag[objectDownload]}} \ + --tagging '{"TagSet": [{ "Key": "version", "Value": "'"${applicationVersion}"'" }, {"Key": "'"${tag[key]}"'", "Value": "'"${tag[value]}"'"}, { "Key": "designation", "Value": "'"${designation1}"'" }]}' + + # Create metrics for all tags for downloads to S3 + aws s3api put-bucket-metrics-configuration \ + --bucket ${AWS_BUCKET_NAME} \ + --id ${id0} \ + --metrics-configuration '{"Id": "'"${id0}"'", "Filter": {"And": {"Tags": [{"Key": "'"${tag[key]}"'", "Value": "'"${tag[value]}"'"}, {"Key": "designation", "Value": "'"${designation0}"'"}, {"Key": "version", "Value": "'"${applicationVersion}"'"} ]}}}' + + # Create metrics for all tags for upgrades to S3 + aws s3api put-bucket-metrics-configuration \ + --bucket ${AWS_BUCKET_NAME} \ + --id ${id1} \ + --metrics-configuration '{"Id": "'"${id1}"'", "Filter": {"And": {"Tags": [{"Key": "'"${tag[key]}"'", "Value": "'"${tag[value]}"'"}, {"Key": "designation", "Value": "'"${designation1}"'"}, {"Key": "version", "Value": "'"${applicationVersion}"'"}]}}}' + + done + +workflows: + build: + jobs: + # unit tests (on any commit) + - unit-tests-ui: + name: UTest - UI + - unit-tests-api: + name: UTest - API + + # integration tests run in parallel (on any commit) + # target server runs locally to calculate code coverage + - integration-tests-run: + matrix: + alias: itest-code + parameters: + rte: *iTestsNames + name: ITest - << matrix.rte >> (code) + - integration-tests-coverage: + name: ITest - Final coverage + requires: + - itest-code + # e2e tests (doesn't affect pipeline even if fail) + - docker: + name: Build docker image + filters: &e2eFilter + branches: + only: + - /^release.*/ + - /^e2e.*/ + - /^feature/e2e.*/ + - develop + - master + - e2e-tests: + name: E2ETest + build: docker + filters: *e2eFilter + requires: + - Build docker image + + # build and release electron app (dev) + - dev-build-approve: + name: Build dev app + type: approval + requires: + - UTest - UI + - UTest - API + - ITest - Final coverage + <<: *devFilter + - setup-sign-certificates: + name: Setup sign certificates (dev) + requires: + - Build dev app + <<: *devFilter + - setup-build: + name: Setup build (dev) + env: dev + requires: + - Setup sign certificates (dev) + <<: *devFilter + - linux: + name: Build app - Linux (dev) + requires: &stageElectronBuildRequires + - Setup build (dev) + <<: *devFilter + - macosx: + name: Build app - MacOS (dev) + requires: *stageElectronBuildRequires + <<: *devFilter + - windows: + name: Build app - Windows (dev) + requires: *stageElectronBuildRequires + <<: *devFilter + - store-build-artifacts: + name: Store build artifacts (dev) + requires: + - Build app - Linux (dev) + - Build app - MacOS (dev) + - Build app - Windows (dev) + + # build and release electron app (stage) + - setup-sign-certificates: + name: Setup sign certificates (stage) + requires: + - UTest - UI + - UTest - API + - ITest - Final coverage + <<: *stageFilter + - setup-build: + name: Setup build (stage) + requires: + - Setup sign certificates (stage) + <<: *stageFilter + - linux: + name: Build app - Linux (stage) + requires: &stageElectronBuildRequires + - Setup build (stage) + <<: *stageFilter + - macosx: + name: Build app - MacOS (stage) + requires: *stageElectronBuildRequires + <<: *stageFilter + - windows: + name: Build app - Windows (stage) + requires: *stageElectronBuildRequires + <<: *stageFilter + - release-github: + name: Release Github (stage) + requires: + - Build app - Linux (stage) + - Build app - MacOS (stage) + - Build app - Windows (stage) + # # integration tests over built docker image (TBD) + # - integration-tests-run: + # matrix: + # alias: itest-code + # parameters: + # rte: *iTestsNames + # name: Itest - << matrix.rte >> (code) + # Needs approval from QA team that build was tested before merging to master + - qa-approve: + name: Approved by QA team + type: approval + requires: + - Release Github (stage) + + # build and release electron app (prod) + - setup-sign-certificates: + name: Setup sign certificates (prod) + requires: + - UTest - UI + - UTest - API + - ITest - Final coverage + <<: *prodFilter + - setup-build: + name: Setup build (prod) + env: prod + requires: + - Setup sign certificates (prod) + <<: *prodFilter + - linux: + name: Build app - Linux (prod) + env: prod + requires: &prodElectronBuildRequires + - Setup build (prod) + <<: *prodFilter + - macosx: + name: Build app - MacOS (prod) + env: prod + requires: *prodElectronBuildRequires + <<: *prodFilter + - windows: + name: Build app - Windows (prod) + env: prod + requires: *prodElectronBuildRequires + <<: *prodFilter + # virus check all electron apps (prod only) + - virustotal: + name: Virus check - AppImage (prod) + ext: AppImage + requires: + - Build app - Linux (prod) + - virustotal: + name: Virus check - deb (prod) + ext: deb + requires: + - Build app - Linux (prod) + - virustotal: + name: Virus check - dmg (prod) + ext: dmg + requires: + - Build app - MacOS (prod) + - virustotal: + name: Virus check - exe (prod) + ext: exe + requires: + - Build app - Windows (prod) + + # upload release to AWS and GitHub + - release-aws-private: + name: Release AWS S3 Private (prod) + requires: + - Virus check - AppImage (prod) + - Virus check - deb (prod) + - Virus check - dmg (prod) + - Virus check - exe (prod) + + - release-github: + name: Release Github (prod) + env: prod + requires: + - Virus check - AppImage (prod) + - Virus check - deb (prod) + - Virus check - dmg (prod) + - Virus check - exe (prod) + + # Manual approve for publish release + - approve-publish: + name: Approve Publish Release (prod) + type: approval + requires: + - Release AWS S3 Private (prod) + - Release Github (prod) + <<: *prodFilter # double check for "master" + + # Publish release + - publish-prod-aws: + name: Publish AWS S3 + requires: + - Approve Publish Release (prod) + <<: *prodFilter # double check for "master" + + # Nightly tests + nightly: + triggers: + - schedule: + cron: '0 0 * * *' + filters: + branches: + only: + - master + - develop + jobs: + - docker: + name: Build docker image + - integration-tests-run: + matrix: + alias: itest-docker + parameters: + rte: *iTestsNames + build: ['docker'] + report: [true] + name: ITest - << matrix.rte >> (docker) + requires: + - Build docker image + - e2e-tests: + name: E2ETest - Nightly + build: docker + report: true + requires: + - Build docker image diff --git a/.circleci/deps-audit-report.js b/.circleci/deps-audit-report.js new file mode 100644 index 0000000000..e36636b0b6 --- /dev/null +++ b/.circleci/deps-audit-report.js @@ -0,0 +1,83 @@ +const fs = require('fs'); +const { exec } = require("child_process"); + +const FILENAME = process.env.FILENAME; +const DEPS = process.env.DEPS || ''; +const file = `${FILENAME}`; +const outputFile = `slack.${FILENAME}`; + +function generateSlackMessage (summary) { + const message = { + text: `DEPS AUDIT: *${DEPS}* result (Branch: *${process.env.CIRCLE_BRANCH}*)` + + `\nScanned ${summary.totalDependencies} dependencies` + + `\n`, + attachments: [], + }; + + if (summary.totalVulnerabilities) { + if (summary.vulnerabilities.critical) { + message.attachments.push({ + title: 'Critical', + color: '#641E16', + text: `${summary.vulnerabilities.critical}`, + }); + } + if (summary.vulnerabilities.high) { + message.attachments.push({ + title: 'High', + color: '#C0392B', + text: `${summary.vulnerabilities.high}`, + }); + } + if (summary.vulnerabilities.moderate) { + message.attachments.push({ + title: 'Moderate', + color: '#F5B041', + text: `${summary.vulnerabilities.moderate}`, + }); + } + if (summary.vulnerabilities.low) { + message.attachments.push({ + title: 'Low', + color: '#F9E79F', + text: `${summary.vulnerabilities.low}`, + }); + } + if (summary.vulnerabilities.info) { + message.attachments.push({ + title: 'Info', + text: `${summary.vulnerabilities.info}`, + }); + } + } else { + message.attachments.push( + { + title: 'No vulnerabilities found', + color: 'good' + } + ); + } + + return message; +} + +async function main() { + const lastAuditLine = await new Promise((resolve, reject) => { + exec(`tail -n 1 ${file}`, (error, stdout, stderr) => { + if (error) { + return reject(error); + } + resolve(stdout); + }) + }) + + const { data: summary } = JSON.parse(`${lastAuditLine}`); + const vulnerabilities = summary?.vulnerabilities || {}; + summary.totalVulnerabilities = Object.values(vulnerabilities).reduce((totalVulnerabilities, val) => totalVulnerabilities + val) + fs.writeFileSync(outputFile, JSON.stringify({ + channel: process.env.SLACK_AUDIT_REPORT_CHANNEL, + ...generateSlackMessage(summary), + })); +} + +main(); diff --git a/.circleci/e2e-results.js b/.circleci/e2e-results.js new file mode 100644 index 0000000000..d5fa29d2f3 --- /dev/null +++ b/.circleci/e2e-results.js @@ -0,0 +1,50 @@ +const fs = require('fs'); + +const file = 'tests/e2e/results/e2e.results.json' +const results = { + message: { + text: `*E2ETest - All* (Branch: *${process.env.CIRCLE_BRANCH}*)` + + `\n`, + attachments: [], + }, +}; + +const result = JSON.parse(fs.readFileSync(file, 'utf-8')) +const testRunResult = { + color: '#36a64f', + title: `Started at: *${result.startTime}`, + text: `Executed ${result.total} in ${(new Date(result.endTime) - new Date(result.startTime)) / 1000}s`, + fields: [ + { + title: 'Passed', + value: result.passed, + short: true, + }, + { + title: 'Skipped', + value: result.skipped, + short: true, + }, + ], +}; +const failed = result.total - result.passed; +if (failed) { + results.passed = false; + testRunResult.color = '#cc0000'; + testRunResult.fields.push({ + title: 'Failed', + value: failed, + short: true, + }); +} + +results.message.attachments.push(testRunResult); + +if (results.passed === false) { + results.message.text = ' ' + results.message.text; +} + +fs.writeFileSync('e2e.report.json', JSON.stringify({ + channel: process.env.SLACK_TEST_REPORT_CHANNEL, + ...results.message, +})); diff --git a/.circleci/itest-results.js b/.circleci/itest-results.js new file mode 100644 index 0000000000..5d1309d0a1 --- /dev/null +++ b/.circleci/itest-results.js @@ -0,0 +1,51 @@ +const fs = require('fs'); + +const file = 'redisinsight/api/test/test-runs/coverage/test-run-result.json' + +const results = { + message: { + text: `*ITest - ${process.env.ITEST_NAME}* (Branch: *${process.env.CIRCLE_BRANCH}*)` + + `\n`, + attachments: [], + }, +}; + +const result = JSON.parse(fs.readFileSync(file, 'utf-8')) +const testRunResult = { + color: '#36a64f', + title: `Started at: ${result.stats.start}`, + text: `Executed ${result.stats.tests} in ${result.stats.duration / 1000}s`, + fields: [ + { + title: 'Passed', + value: result.stats.passes, + short: true, + }, + { + title: 'Skipped', + value: result.stats.pending, + short: true, + }, + ], +}; + +if (result.stats.failures) { + results.passed = false; + testRunResult.color = '#cc0000'; + testRunResult.fields.push({ + title: 'Failed', + value: result.stats.failures, + short: true, + }); +} + +results.message.attachments.push(testRunResult); + +if (results.passed === false) { + results.message.text = ' ' + results.message.text; +} + +fs.writeFileSync('itests.report.json', JSON.stringify({ + channel: process.env.SLACK_TEST_REPORT_CHANNEL, + ...results.message, +})); diff --git a/.circleci/lint-report.js b/.circleci/lint-report.js new file mode 100644 index 0000000000..df347de88a --- /dev/null +++ b/.circleci/lint-report.js @@ -0,0 +1,62 @@ +const fs = require('fs'); + +const FILENAME = process.env.FILENAME || 'lint.audit.json'; +const WORKDIR = process.env.WORKDIR || '.'; +const TARGET = process.env.TARGET || ''; +const file = `${WORKDIR}/${FILENAME}`; +const outputFile = `${WORKDIR}/slack.${FILENAME}`; + +function generateSlackMessage (summary) { + const message = { + text: `CODE SCAN: *${TARGET}* result (Branch: *${process.env.CIRCLE_BRANCH}*)` + + `\n`, + attachments: [], + }; + + if (summary.total) { + if (summary.errors) { + message.attachments.push({ + title: 'Errors', + color: '#C0392B', + text: `${summary.errors}`, + }); + } + if (summary.warnings) { + message.attachments.push({ + title: 'Warnings', + color: '#F5B041', + text: `${summary.warnings}`, + }); + } + } else { + message.attachments.push( + { + title: 'No issues found', + color: 'good' + } + ); + } + + return message; +} + +async function main() { + const summary = { + errors: 0, + warnings: 0, + }; + const scanResult = JSON.parse(fs.readFileSync(file)); + scanResult.forEach(fileResult => { + summary.errors += fileResult.errorCount; + summary.warnings += fileResult.warningCount; + }); + + summary.total = summary.errors + summary.warnings; + + fs.writeFileSync(outputFile, JSON.stringify({ + channel: process.env.SLACK_AUDIT_REPORT_CHANNEL, + ...generateSlackMessage(summary), + })); +} + +main(); diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..cd4ff83d5c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.idea +.vscode +.circleci + +coverage +dll +node_modules +release + +redisinsight/dist +redisinsight/node_modules +redisinsight/main.prod.js + +redisinsight/api/.nyc_output +redisinsight/api/coverage +redisinsight/api/dist +redisinsight/api/node_modules + +redisinsight/ui/dist diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..70e47c9f5f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +quote_type = single + +[*.md] +trim_trailing_whitespace = false + +[/tests/**.ts] +indent_size = 4 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..699437a49e --- /dev/null +++ b/.eslintignore @@ -0,0 +1,56 @@ +# Ignores folders covered with custom linters configs +redisinsight/api +tests + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release +.eslintcache + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# OSX +.DS_Store + +# App packaged +release +*.main.prod.js +*.renderer.prod.js +scripts +configs +dist +dll +*.main.js + +.idea +npm-debug.log.* +__snapshots__ + +# Package.json +package.json +.travis.yml +*.css.d.ts +*.sass.d.ts +*.scss.d.ts diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..fb8ebf6309 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,17 @@ +module.exports = { + root: true, + extends: ['airbnb-typescript'], + plugins: ['@typescript-eslint'], + parser: '@typescript-eslint/parser', + rules: { + 'max-len': ['warn', 120], + 'class-methods-use-this': 'off', + 'import/no-extraneous-dependencies': 'off', // temporary disabled + }, + parserOptions: { + project: './tsconfig.json', + }, + ignorePatterns: [ + 'redisinsight/ui', + ], +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..132e4b9bb1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text eol=lf +*.exe binary +*.png binary +*.jpg binary +*.jpeg binary +*.ico binary +*.icns binary +*.otf binary +*.eot binary +*.ttf binary +*.woff binary +*.woff2 binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..ffe40fac58 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# Add reviewers for the most sensitive folders +/.github/ egor.zalenski@softeq.com artem.horuzhenko@softeq.com +/.circleci/ egor.zalenski@softeq.com artem.horuzhenko@softeq.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..0cc65ec44a --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# compiled output +dist +node_modules + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace +*.css.d.ts +*.sass.d.ts +*.scss.d.ts +**/*.scss.d.ts + +# IDE - VSCode +.vscode + +# App packaged +release +main.prod.js +main.prod.js.map +redisinsight/ui/main.prod.js +redisinsight/ui/main.prod.js.map +renderer.prod.js +renderer.prod.js.map +redisinsight/ui/style.css +redisinsight/ui/style.css.map +redisinsight/ui/dist +dist +distWeb +dll +main.js +main.js.map +vendor + +# E2E tests report +/tests/e2e/report + +# Parcel +.parcel-cache diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000000..a156e63075 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,4 @@ +# This will set the --ignore-scripts flag whenever running yarn add +#--ignore-scripts true +#--install.ignore-scripts true +--add.ignore-scripts true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..ea1b89f984 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# 0.0.0 + +#### Features + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..6474ad4903 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +FROM node:14.17-alpine as front +RUN apk update +RUN apk add --no-cache --virtual .gyp \ + python \ + make \ + g++ +WORKDIR /usr/src/app +COPY package.json yarn.lock babel.config.js tsconfig.json ./ +COPY configs ./configs +COPY scripts ./scripts +COPY redisinsight ./redisinsight +RUN SKIP_POSTINSTALL=1 yarn install +RUN yarn build:web +RUN yarn build:statics + +FROM node:14.17-alpine as back +WORKDIR /usr/src/app +COPY redisinsight/api/package.json redisinsight/api/yarn.lock ./ +RUN yarn install +COPY redisinsight/api ./ +COPY --from=front /usr/src/app/redisinsight/api/src/static ./src/static +RUN yarn run build:prod + +FROM node:14.17-slim +# Set up mDNS functionality, to play well with Redis Enterprise +# clusters on the network. +RUN set -ex \ + && DEPS="avahi-daemon libnss-mdns" \ + && apt-get update && apt-get install -y --no-install-recommends $DEPS \ + # Disable nss-mdns's two-label limit heuristic so that host names + # with multiple labels can be resolved. + # E.g. redis-12000.rediscluster.local, which has 3 labels. + # (https://github.com/lathiat/nss-mdns#etcmdnsallow) + && echo '*' > /etc/mdns.allow \ + # Configure NSSwitch to use the mdns4 plugin so mdns.allow is respected + && sed -i "s/hosts:.*/hosts: files mdns4 dns/g" /etc/nsswitch.conf \ + # We run a `avahi-daemon` without `dbus` so that we can start it as a + # non-root user. `dbus` requires root permissions to start. And + # anyway, there's a way to run `avahi-daemon` without `dbus` so why + # shouldn't we use it. https://linux.die.net/man/5/avahi-daemon.conf + && printf "[server]\nenable-dbus=no\n" >> /etc/avahi/avahi-daemon.conf \ + && chmod 777 /etc/avahi/avahi-daemon.conf \ + # We create the directory because when the first time `avahi-daemon` + # is run, the directory doesn't exist and the `avahi-daemon` must have + # permissions to create the directory under `/var`. + && mkdir -p /var/run/avahi-daemon \ + # Change the permissions of the directories avahi will use. + && chown avahi:avahi /var/run/avahi-daemon \ + && chmod 777 /var/run/avahi-daemon + +RUN apt-get install -y dbus-x11 gnome-keyring libsecret-1-0 +RUN dbus-uuidgen > /var/lib/dbus/machine-id + +ARG NODE_ENV=production +ARG SERVER_TLS_CERT +ARG SERVER_TLS_KEY +ENV SERVER_TLS_CERT=${SERVER_TLS_CERT} +ENV SERVER_TLS_KEY=${SERVER_TLS_KEY} +ENV NODE_ENV=${NODE_ENV} +ENV SERVER_STATIC_CONTENT=true +ENV BUILD_TYPE='DOCKER_ON_PREMISE' +WORKDIR /usr/src/app +COPY --from=back /usr/src/app/dist ./redisinsight/api/dist +COPY --from=front /usr/src/app/redisinsight/ui/dist ./redisinsight/ui/dist + +# Build BE prod dependencies here to build native modules +COPY redisinsight/api/package.json redisinsight/api/yarn.lock ./redisinsight/api/ +RUN yarn --cwd ./redisinsight/api install --production +COPY redisinsight/api/.yarnclean.prod ./redisinsight/api/.yarnclean +RUN yarn --cwd ./redisinsight/api autoclean --force + +COPY ./docker-entry.sh ./ +RUN chmod +x docker-entry.sh + +EXPOSE 5000 + +ENTRYPOINT ["./docker-entry.sh", "node", "redisinsight/api/dist/src/main"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..7ad9b79ae5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,557 @@ +Server Side Public License + VERSION 1, OCTOBER 16, 2018 + + Copyright © 2018 MongoDB, Inc. + + Everyone is permitted to copy and distribute verbatim copies of this + license document, but changing it is not allowed. + + TERMS AND CONDITIONS + + 0. Definitions. + + “This License” refers to Server Side Public License. + + “Copyright” also means copyright-like laws that apply to other kinds of + works, such as semiconductor masks. + + “The Program” refers to any copyrightable work licensed under this + License. Each licensee is addressed as “you”. “Licensees” and + “recipients” may be individuals or organizations. + + To “modify” a work means to copy from or adapt all or part of the work in + a fashion requiring copyright permission, other than the making of an + exact copy. The resulting work is called a “modified version” of the + earlier work or a work “based on” the earlier work. + + A “covered work” means either the unmodified Program or a work based on + the Program. + + To “propagate” a work means to do anything with it that, without + permission, would make you directly or secondarily liable for + infringement under applicable copyright law, except executing it on a + computer or modifying a private copy. Propagation includes copying, + distribution (with or without modification), making available to the + public, and in some countries other activities as well. + + To “convey” a work means any kind of propagation that enables other + parties to make or receive copies. Mere interaction with a user through a + computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays “Appropriate Legal Notices” to the + extent that it includes a convenient and prominently visible feature that + (1) displays an appropriate copyright notice, and (2) tells the user that + there is no warranty for the work (except to the extent that warranties + are provided), that licensees may convey the work under this License, and + how to view a copy of this License. If the interface presents a list of + user commands or options, such as a menu, a prominent item in the list + meets this criterion. + + 1. Source Code. + + The “source code” for a work means the preferred form of the work for + making modifications to it. “Object code” means any non-source form of a + work. + + A “Standard Interface” means an interface that either is an official + standard defined by a recognized standards body, or, in the case of + interfaces specified for a particular programming language, one that is + widely used among developers working in that language. The “System + Libraries” of an executable work include anything, other than the work as + a whole, that (a) is included in the normal form of packaging a Major + Component, but which is not part of that Major Component, and (b) serves + only to enable use of the work with that Major Component, or to implement + a Standard Interface for which an implementation is available to the + public in source code form. A “Major Component”, in this context, means a + major essential component (kernel, window system, and so on) of the + specific operating system (if any) on which the executable work runs, or + a compiler used to produce the work, or an object code interpreter used + to run it. + + The “Corresponding Source” for a work in object code form means all the + source code needed to generate, install, and (for an executable work) run + the object code and to modify the work, including scripts to control + those activities. However, it does not include the work's System + Libraries, or general-purpose tools or generally available free programs + which are used unmodified in performing those activities but which are + not part of the work. For example, Corresponding Source includes + interface definition files associated with source files for the work, and + the source code for shared libraries and dynamically linked subprograms + that the work is specifically designed to require, such as by intimate + data communication or control flow between those subprograms and other + parts of the work. + + The Corresponding Source need not include anything that users can + regenerate automatically from other parts of the Corresponding Source. + + The Corresponding Source for a work in source code form is that same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of + copyright on the Program, and are irrevocable provided the stated + conditions are met. This License explicitly affirms your unlimited + permission to run the unmodified Program, subject to section 13. The + output from running a covered work is covered by this License only if the + output, given its content, constitutes a covered work. This License + acknowledges your rights of fair use or other equivalent, as provided by + copyright law. Subject to section 13, you may make, run and propagate + covered works that you do not convey, without conditions so long as your + license otherwise remains in force. You may convey covered works to + others for the sole purpose of having them make modifications exclusively + for you, or provide you with facilities for running those works, provided + that you comply with the terms of this License in conveying all + material for which you do not control copyright. Those thus making or + running the covered works for you must do so exclusively on your + behalf, under your direction and control, on terms that prohibit them + from making any copies of your copyrighted material outside their + relationship with you. + + Conveying under any other circumstances is permitted solely under the + conditions stated below. Sublicensing is not allowed; section 10 makes it + unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological + measure under any applicable law fulfilling obligations under article 11 + of the WIPO copyright treaty adopted on 20 December 1996, or similar laws + prohibiting or restricting circumvention of such measures. + + When you convey a covered work, you waive any legal power to forbid + circumvention of technological measures to the extent such circumvention is + effected by exercising rights under this License with respect to the + covered work, and you disclaim any intention to limit operation or + modification of the work as a means of enforcing, against the work's users, + your or third parties' legal rights to forbid circumvention of + technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you + receive it, in any medium, provided that you conspicuously and + appropriately publish on each copy an appropriate copyright notice; keep + intact all notices stating that this License and any non-permissive terms + added in accord with section 7 apply to the code; keep intact all notices + of the absence of any warranty; and give all recipients a copy of this + License along with the Program. You may charge any price or no price for + each copy that you convey, and you may offer support or warranty + protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to + produce it from the Program, in the form of source code under the terms + of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, + and giving a relevant date. + + b) The work must carry prominent notices stating that it is released + under this License and any conditions added under section 7. This + requirement modifies the requirement in section 4 to “keep intact all + notices”. + + c) You must license the entire work, as a whole, under this License to + anyone who comes into possession of a copy. This License will therefore + apply, along with any applicable section 7 additional terms, to the + whole of the work, and all its parts, regardless of how they are + packaged. This License gives no permission to license the work in any + other way, but it does not invalidate such permission if you have + separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your work + need not make them do so. + + A compilation of a covered work with other separate and independent + works, which are not by their nature extensions of the covered work, and + which are not combined with it such as to form a larger program, in or on + a volume of a storage or distribution medium, is called an “aggregate” if + the compilation and its resulting copyright are not used to limit the + access or legal rights of the compilation's users beyond what the + individual works permit. Inclusion of a covered work in an aggregate does + not cause this License to apply to the other parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms of + sections 4 and 5, provided that you also convey the machine-readable + Corresponding Source under the terms of this License, in one of these + ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium customarily + used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a written + offer, valid for at least three years and valid for as long as you + offer spare parts or customer support for that product model, to give + anyone who possesses the object code either (1) a copy of the + Corresponding Source for all the software in the product that is + covered by this License, on a durable physical medium customarily used + for software interchange, for a price no more than your reasonable cost + of physically performing this conveying of source, or (2) access to + copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This alternative is + allowed only occasionally and noncommercially, and only if you received + the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place + (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to copy + the object code is a network server, the Corresponding Source may be on + a different server (operated by you or a third party) that supports + equivalent copying facilities, provided you maintain clear directions + next to the object code saying where to find the Corresponding Source. + Regardless of what server hosts the Corresponding Source, you remain + obligated to ensure that it is available for as long as needed to + satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you + inform other peers where the object code and Corresponding Source of + the work are being offered to the general public at no charge under + subsection 6d. + + A separable portion of the object code, whose source code is excluded + from the Corresponding Source as a System Library, need not be included + in conveying the object code work. + + A “User Product” is either (1) a “consumer product”, which means any + tangible personal property which is normally used for personal, family, + or household purposes, or (2) anything designed or sold for incorporation + into a dwelling. In determining whether a product is a consumer product, + doubtful cases shall be resolved in favor of coverage. For a particular + product received by a particular user, “normally used” refers to a + typical or common use of that class of product, regardless of the status + of the particular user or of the way in which the particular user + actually uses, or expects or is expected to use, the product. A product + is a consumer product regardless of whether the product has substantial + commercial, industrial or non-consumer uses, unless such uses represent + the only significant mode of use of the product. + + “Installation Information” for a User Product means any methods, + procedures, authorization keys, or other information required to install + and execute modified versions of a covered work in that User Product from + a modified version of its Corresponding Source. The information must + suffice to ensure that the continued functioning of the modified object + code is in no case prevented or interfered with solely because + modification has been made. + + If you convey an object code work under this section in, or with, or + specifically for use in, a User Product, and the conveying occurs as part + of a transaction in which the right of possession and use of the User + Product is transferred to the recipient in perpetuity or for a fixed term + (regardless of how the transaction is characterized), the Corresponding + Source conveyed under this section must be accompanied by the + Installation Information. But this requirement does not apply if neither + you nor any third party retains the ability to install modified object + code on the User Product (for example, the work has been installed in + ROM). + + The requirement to provide Installation Information does not include a + requirement to continue to provide support service, warranty, or updates + for a work that has been modified or installed by the recipient, or for + the User Product in which it has been modified or installed. Access + to a network may be denied when the modification itself materially + and adversely affects the operation of the network or violates the + rules and protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, in + accord with this section must be in a format that is publicly documented + (and with an implementation available to the public in source code form), + and must require no special password or key for unpacking, reading or + copying. + + 7. Additional Terms. + + “Additional permissions” are terms that supplement the terms of this + License by making exceptions from one or more of its conditions. + Additional permissions that are applicable to the entire Program shall be + treated as though they were included in this License, to the extent that + they are valid under applicable law. If additional permissions apply only + to part of the Program, that part may be used separately under those + permissions, but the entire Program remains governed by this License + without regard to the additional permissions. When you convey a copy of + a covered work, you may at your option remove any additional permissions + from that copy, or from any part of it. (Additional permissions may be + written to require their own removal in certain cases when you modify the + work.) You may place additional permissions on material, added by you to + a covered work, for which you have or can give appropriate copyright + permission. + + Notwithstanding any other provision of this License, for material you add + to a covered work, you may (if authorized by the copyright holders of + that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade + names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material + by anyone who conveys the material (or modified versions of it) with + contractual assumptions of liability to the recipient, for any + liability that these contractual assumptions directly impose on those + licensors and authors. + + All other non-permissive additional terms are considered “further + restrictions” within the meaning of section 10. If the Program as you + received it, or any part of it, contains a notice stating that it is + governed by this License along with a term that is a further restriction, + you may remove that term. If a license document contains a further + restriction but permits relicensing or conveying under this License, you + may add to a covered work material governed by the terms of that license + document, provided that the further restriction does not survive such + relicensing or conveying. + + If you add terms to a covered work in accord with this section, you must + place, in the relevant source files, a statement of the additional terms + that apply to those files, or a notice indicating where to find the + applicable terms. Additional terms, permissive or non-permissive, may be + stated in the form of a separately written license, or stated as + exceptions; the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly + provided under this License. Any attempt otherwise to propagate or modify + it is void, and will automatically terminate your rights under this + License (including any patent licenses granted under the third paragraph + of section 11). + + However, if you cease all violation of this License, then your license + from a particular copyright holder is reinstated (a) provisionally, + unless and until the copyright holder explicitly and finally terminates + your license, and (b) permanently, if the copyright holder fails to + notify you of the violation by some reasonable means prior to 60 days + after the cessation. + + Moreover, your license from a particular copyright holder is reinstated + permanently if the copyright holder notifies you of the violation by some + reasonable means, this is the first time you have received notice of + violation of this License (for any work) from that copyright holder, and + you cure the violation prior to 30 days after your receipt of the notice. + + Termination of your rights under this section does not terminate the + licenses of parties who have received copies or rights from you under + this License. If your rights have been terminated and not permanently + reinstated, you do not qualify to receive new licenses for the same + material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or run a + copy of the Program. Ancillary propagation of a covered work occurring + solely as a consequence of using peer-to-peer transmission to receive a + copy likewise does not require acceptance. However, nothing other than + this License grants you permission to propagate or modify any covered + work. These actions infringe copyright if you do not accept this License. + Therefore, by modifying or propagating a covered work, you indicate your + acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically receives + a license from the original licensors, to run, modify and propagate that + work, subject to this License. You are not responsible for enforcing + compliance by third parties with this License. + + An “entity transaction” is a transaction transferring control of an + organization, or substantially all assets of one, or subdividing an + organization, or merging organizations. If propagation of a covered work + results from an entity transaction, each party to that transaction who + receives a copy of the work also receives whatever licenses to the work + the party's predecessor in interest had or could give under the previous + paragraph, plus a right to possession of the Corresponding Source of the + work from the predecessor in interest, if the predecessor has it or can + get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the rights + granted or affirmed under this License. For example, you may not impose a + license fee, royalty, or other charge for exercise of rights granted + under this License, and you may not initiate litigation (including a + cross-claim or counterclaim in a lawsuit) alleging that any patent claim + is infringed by making, using, selling, offering for sale, or importing + the Program or any portion of it. + + 11. Patents. + + A “contributor” is a copyright holder who authorizes use under this + License of the Program or a work on which the Program is based. The work + thus licensed is called the contributor's “contributor version”. + + A contributor's “essential patent claims” are all patent claims owned or + controlled by the contributor, whether already acquired or hereafter + acquired, that would be infringed by some manner, permitted by this + License, of making, using, or selling its contributor version, but do not + include claims that would be infringed only as a consequence of further + modification of the contributor version. For purposes of this definition, + “control” includes the right to grant patent sublicenses in a manner + consistent with the requirements of this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free + patent license under the contributor's essential patent claims, to make, + use, sell, offer for sale, import and otherwise run, modify and propagate + the contents of its contributor version. + + In the following three paragraphs, a “patent license” is any express + agreement or commitment, however denominated, not to enforce a patent + (such as an express permission to practice a patent or covenant not to + sue for patent infringement). To “grant” such a patent license to a party + means to make such an agreement or commitment not to enforce a patent + against the party. + + If you convey a covered work, knowingly relying on a patent license, and + the Corresponding Source of the work is not available for anyone to copy, + free of charge and under the terms of this License, through a publicly + available network server or other readily accessible means, then you must + either (1) cause the Corresponding Source to be so available, or (2) + arrange to deprive yourself of the benefit of the patent license for this + particular work, or (3) arrange, in a manner consistent with the + requirements of this License, to extend the patent license to downstream + recipients. “Knowingly relying” means you have actual knowledge that, but + for the patent license, your conveying the covered work in a country, or + your recipient's use of the covered work in a country, would infringe + one or more identifiable patents in that country that you have reason + to believe are valid. + + If, pursuant to or in connection with a single transaction or + arrangement, you convey, or propagate by procuring conveyance of, a + covered work, and grant a patent license to some of the parties receiving + the covered work authorizing them to use, propagate, modify or convey a + specific copy of the covered work, then the patent license you grant is + automatically extended to all recipients of the covered work and works + based on it. + + A patent license is “discriminatory” if it does not include within the + scope of its coverage, prohibits the exercise of, or is conditioned on + the non-exercise of one or more of the rights that are specifically + granted under this License. You may not convey a covered work if you are + a party to an arrangement with a third party that is in the business of + distributing software, under which you make payment to the third party + based on the extent of your activity of conveying the work, and under + which the third party grants, to any of the parties who would receive the + covered work from you, a discriminatory patent license (a) in connection + with copies of the covered work conveyed by you (or copies made from + those copies), or (b) primarily for and in connection with specific + products or compilations that contain the covered work, unless you + entered into that arrangement, or that patent license was granted, prior + to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting any + implied license or other defenses to infringement that may otherwise be + available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or + otherwise) that contradict the conditions of this License, they do not + excuse you from the conditions of this License. If you cannot use, + propagate or convey a covered work so as to satisfy simultaneously your + obligations under this License and any other pertinent obligations, then + as a consequence you may not use, propagate or convey it at all. For + example, if you agree to terms that obligate you to collect a royalty for + further conveying from those to whom you convey the Program, the only way + you could satisfy both those terms and this License would be to refrain + entirely from conveying the Program. + + 13. Offering the Program as a Service. + + If you make the functionality of the Program or a modified version + available to third parties as a service, you must make the Service Source + Code available via network download to everyone at no charge, under the + terms of this License. Making the functionality of the Program or + modified version available to third parties as a service includes, + without limitation, enabling third parties to interact with the + functionality of the Program or modified version remotely through a + computer network, offering a service the value of which entirely or + primarily derives from the value of the Program or modified version, or + offering a service that accomplishes for users the primary purpose of the + Program or modified version. + + “Service Source Code” means the Corresponding Source for the Program or + the modified version, and the Corresponding Source for all programs that + you use to make the Program or modified version available as a service, + including, without limitation, management software, user interfaces, + application program interfaces, automation software, monitoring software, + backup software, storage software and hosting software, all such that a + user could run an instance of the service using the Service Source Code + you make available. + + 14. Revised Versions of this License. + + MongoDB, Inc. may publish revised and/or new versions of the Server Side + Public License from time to time. Such new versions will be similar in + spirit to the present version, but may differ in detail to address new + problems or concerns. + + Each version is given a distinguishing version number. If the Program + specifies that a certain numbered version of the Server Side Public + License “or any later version” applies to it, you have the option of + following the terms and conditions either of that numbered version or of + any later version published by MongoDB, Inc. If the Program does not + specify a version number of the Server Side Public License, you may + choose any version ever published by MongoDB, Inc. + + If the Program specifies that a proxy can decide which future versions of + the Server Side Public License can be used, that proxy's public statement + of acceptance of a version permanently authorizes you to choose that + version for the Program. + + Later license versions may give you additional or different permissions. + However, no additional obligations are imposed on any author or copyright + holder as a result of your choosing to follow a later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF + ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING + ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF + THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO + LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU + OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER + PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE + POSSIBILITY OF SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided above + cannot be given local legal effect according to their terms, reviewing + courts shall apply local law that most closely approximates an absolute + waiver of all civil liability in connection with the Program, unless a + warranty or assumption of liability accompanies a copy of the Program in + return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index f1555b692c..18e0cd6d9f 100644 --- a/README.md +++ b/README.md @@ -1 +1,130 @@ # RedisInsight + +[![CircleCI](https://circleci.com/gh/RedisInsight/RedisInsight/tree/master.svg?style=svg)](https://circleci.com/gh/RedisInsight/RedisInsight/tree/master) + +Awesome Redis GUI written in Electron, NodeJS and React + +## Directory Structure + +- `redisinsight/ui` - Contains the frontend code. +- `redisinsight/api` - Contains the backend code. +- `scripts` - Build scripts and other build-related files +- `configs` - Webpack configuration files and other build-related files +- `tests` - Contains the e2e and integration tests. + +## Development Workflow + +### Installation + +```bash +$ yarn install +$ yarn --cwd redisinsight/api/ +``` + +### Packaging the desktop app + +After you have installed all dependencies you can package the app. +Run `yarn package:prod` to package app for the local platform: + +```bash +# Production +$ yarn package:prod +``` + +And packaged installer will be in the folder _release_ + +### Running the desktop app + +After you have installed all dependencies you can now run the app. +Run `yarn start` to start an electron application that will watch and build for you. + +```bash +# Development +$ yarn start +``` + +### Running frontend part of the app + +After you have installed all dependencies you can now run the app. +Run `yarn start:web` to start a local server that will watch and build for you. + +```bash +# Development +$ yarn start:web +``` + +### Running backend part of the app + +After you have installed all dependencies run `yarn --cwd redisinsight/api/ start:dev` to start a local API at `localhost:5000`. + +```bash +# Development +$ yarn --cwd redisinsight/api/ start:dev +``` + +While the API is running, open your browser and navigate to http://localhost:5000/api/docs. You should see the Swagger UI. + +### Building frontend part of the app + +Run `yarn build:web` to build fronted to `/redisinsight/ui/dist/`. + +## Docker + +There are 2 different docker images available + +- Image with API and UI +- Image with API only + +#### Build Docker image with UI + +```bash + docker build . +``` + +Image exposes 5000 port + +Api docs - /api/docs + +Main UI - / + +Example: + +```bash + docker build -t redisinsight . +``` + +```bash + docker run -p 5000:5000 -d --cap-add ipc_lock redisinsight +``` + +Then api docs and main ui should be available on http://localhost/api/docs and http://localhost + +#### Build Docker with API only + +Image exposes 5000 port + +Api docs - /api/docs + +Example: + +```bash + docker build -f api.Dockerfile -t api.redisinsight . +``` + +```bash + docker run -p 5000:5000 -d --cap-add ipc_lock api.redisinsight +``` + +Then api docs and main ui should be available on http://localhost/api/docs + +## Continuous Integration + +## Related Repositories + +## Running e2e tests in root tests/e2e + +- To run E2E tests run command: + +```bash + yarn test-chrome +``` diff --git a/api-docker-entry.sh b/api-docker-entry.sh new file mode 100644 index 0000000000..c30c7de723 --- /dev/null +++ b/api-docker-entry.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# Initializing system's secret storage +eval "$(dbus-launch --sh-syntax)" + +mkdir -p ~/.cache +mkdir -p ~/.local/share/keyrings +# fix "Remote error from secret service: +# org.freedesktop.Secret.Error.IsLocked: Cannot create an item in a locked collection" issue +eval "$(echo "$GNOME_KEYRING_PASS" | gnome-keyring-daemon --unlock)" +sleep 1 +eval "$(echo "$GNOME_KEYRING_PASS" | gnome-keyring-daemon --start)" + +exec "$@" diff --git a/api.Dockerfile b/api.Dockerfile new file mode 100644 index 0000000000..905f332599 --- /dev/null +++ b/api.Dockerfile @@ -0,0 +1,42 @@ +FROM node:14.17-alpine as build + +RUN apk update && apk add bash libsecret dbus-x11 gnome-keyring +RUN dbus-uuidgen > /var/lib/dbus/machine-id + +WORKDIR /usr/src/app + +COPY redisinsight/api/package.json redisinsight/api/yarn.lock ./ + +RUN yarn install + +COPY redisinsight/api ./ + +RUN yarn run build:prod + +RUN rm -rf node_modules/ + +RUN yarn install --production +RUN cp .yarnclean.prod .yarnclean && yarn autoclean --force + +# Production image +FROM node:14.17-alpine as production + +RUN apk update && apk add bash libsecret dbus-x11 gnome-keyring +RUN dbus-uuidgen > /var/lib/dbus/machine-id + +ARG NODE_ENV=production +ENV NODE_ENV=${NODE_ENV} +ENV BUILD_TYPE='DOCKER_ON_PREMISE' + +WORKDIR /usr/src/app + +COPY --from=build /usr/src/app/dist ./dist +COPY --from=build /usr/src/app/node_modules ./node_modules +COPY ./api-docker-entry.sh ./ +RUN chmod +x api-docker-entry.sh + +ENTRYPOINT ["./api-docker-entry.sh"] +CMD ["node", "dist/src/main"] + +EXPOSE 5000 + diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000000..4dcf484ca2 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,55 @@ +/* eslint global-require: off, import/no-extraneous-dependencies: off */ + +const developmentEnv = ['development', 'test']; + +const developmentPlugins = [ + require('@babel/plugin-transform-runtime'), + require('react-hot-loader/babel'), +]; + +const productionPlugins = [ + require('babel-plugin-dev-expression'), + require('@babel/plugin-transform-react-constant-elements'), + require('@babel/plugin-transform-react-inline-elements'), + require('babel-plugin-transform-react-remove-prop-types'), +]; + +module.exports = (api) => { + const development = api.env(developmentEnv); + + return { + presets: [ + require('@babel/preset-env'), + require('@babel/preset-typescript'), + [require('@babel/preset-react'), { development }], + ], + plugins: [ + // Stage 0 + require('@babel/plugin-proposal-function-bind'), + + // Stage 1 + require('@babel/plugin-proposal-export-default-from'), + require('@babel/plugin-proposal-logical-assignment-operators'), + [require('@babel/plugin-proposal-optional-chaining'), { loose: false }], + [require('@babel/plugin-proposal-pipeline-operator'), { proposal: 'minimal' }], + [require('@babel/plugin-proposal-nullish-coalescing-operator'), { loose: false }], + require('@babel/plugin-proposal-do-expressions'), + + // Stage 2 + [require('@babel/plugin-proposal-decorators'), { legacy: true }], + require('babel-plugin-parameter-decorator'), + require('@babel/plugin-proposal-function-sent'), + require('@babel/plugin-proposal-export-namespace-from'), + require('@babel/plugin-proposal-numeric-separator'), + require('@babel/plugin-proposal-throw-expressions'), + + // Stage 3 + require('@babel/plugin-syntax-dynamic-import'), + require('@babel/plugin-syntax-import-meta'), + [require('@babel/plugin-proposal-class-properties'), { loose: true }], + require('@babel/plugin-proposal-json-strings'), + + ...(development ? developmentPlugins : productionPlugins), + ], + }; +}; diff --git a/configs/.eslintrc b/configs/.eslintrc new file mode 100644 index 0000000000..89d242ba72 --- /dev/null +++ b/configs/.eslintrc @@ -0,0 +1,7 @@ +{ + "rules": { + "no-console": "off", + "global-require": "off", + "import/no-dynamic-require": "off" + } +} diff --git a/configs/paths.js b/configs/paths.js new file mode 100644 index 0000000000..2a08f20afa --- /dev/null +++ b/configs/paths.js @@ -0,0 +1,17 @@ +// paths.js + +// Paths will export some path variables that we'll +// use in other Webpack config and server files + +const path = require('path'); +const fs = require('fs'); + +const appDirectory = fs.realpathSync(process.cwd()); +const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath); + +module.exports = { + appAssets: resolveApp('ui/src/assets'), // For images and other assets + appBuild: resolveApp('ui/dist'), // Prod built files end up here + appConfig: resolveApp('ui/config'), // App config files + appSrc: resolveApp('ui/src'), // App source +}; diff --git a/configs/webpack.config.base.js b/configs/webpack.config.base.js new file mode 100644 index 0000000000..06deba8951 --- /dev/null +++ b/configs/webpack.config.base.js @@ -0,0 +1,83 @@ +import path from 'path'; +import webpack from 'webpack'; +import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; +import { dependencies as externals } from '../redisinsight/package.json'; + +export default { + externals: [...Object.keys(externals || {})], + + module: { + rules: [ + { + test: /\.tsx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + cacheDirectory: true, + }, + }, + }, + ], + }, + + output: { + path: path.join(__dirname, '..'), + // commonjs2 https://github.com/webpack/webpack/issues/1114 + libraryTarget: 'commonjs2', + }, + + resolve: { + extensions: ['.js', '.jsx', '.json', '.ts', '.tsx', '.scss'], + plugins: [ + new TsconfigPathsPlugin({ + configFile: path.join(__dirname, '..', 'tsconfig.json'), + }), + ], + alias: { + src: path.resolve(__dirname, '../redisinsight/api/src'), + apiSrc: path.resolve(__dirname, '../redisinsight/api/src'), + uiSrc: path.resolve(__dirname, '../redisinsight/ui/src'), + }, + modules: [path.join(__dirname, '../redisinsight/api'), 'node_modules'], + }, + + plugins: [ + new webpack.EnvironmentPlugin({ + }), + + new webpack.IgnorePlugin({ + checkResource(resource) { + const lazyImports = [ + '@nestjs/microservices', + // '@nestjs/platform-express', + // 'pnpapi', + 'cache-manager', + // 'class-validator', + 'fastify-static', + 'fastify-swagger', + // 'hiredis', + // 'reflect-metadata', + // 'swagger-ui-express', + // 'class-transformer', + // 'class-transformer/storage', + '@nestjs/websockets', + // '@nestjs/core/adapters/http-adapter', + // '@nestjs/core/helpers/router-method-factory', + // '@nestjs/core/metadata-scanner', + '@nestjs/microservices/microservices-module', + '@nestjs/websockets/socket-module', + ]; + if (!lazyImports.includes(resource)) { + return false; + } + try { + require.resolve(resource); + } catch (err) { + return true; + } + return false; + }, + }), + ], +}; diff --git a/configs/webpack.config.eslint.js b/configs/webpack.config.eslint.js new file mode 100644 index 0000000000..b1cf088a40 --- /dev/null +++ b/configs/webpack.config.eslint.js @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/no-self-import +/* eslint import/no-unresolved: off, import/no-self-import: off */ +require('@babel/register'); + +module.exports = require('./webpack.config.renderer.dev.babel').default; diff --git a/configs/webpack.config.main.prod.babel.js b/configs/webpack.config.main.prod.babel.js new file mode 100644 index 0000000000..5731a8cbba --- /dev/null +++ b/configs/webpack.config.main.prod.babel.js @@ -0,0 +1,83 @@ +import path from 'path'; +import webpack from 'webpack'; +import { merge } from 'webpack-merge'; +import TerserPlugin from 'terser-webpack-plugin'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import baseConfig from './webpack.config.base'; +import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; +import { version } from '../redisinsight/package.json'; + +DeleteSourceMaps(); + +const devtoolsConfig = + process.env.DEBUG_PROD === 'true' + ? { + devtool: 'source-map', + } + : {}; + +export default merge(baseConfig, { + ...devtoolsConfig, + + mode: 'development', + + target: 'electron-main', + + entry: './redisinsight/main.dev.ts', + + resolve: { + alias: { + ['apiSrc']: path.resolve(__dirname, '../redisinsight/api/src'), + ['src']: path.resolve(__dirname, '../redisinsight/api/src'), + }, + extensions: ['.tsx', '.ts', '.js', '.jsx'], + }, + + output: { + path: path.join(__dirname, '../redisinsight'), + filename: 'main.prod.js', + }, + + // optimization: { + // minimizer: [ + // new TerserPlugin({ + // parallel: true, + // }), + // ], + // }, + + // alias: { + // 'apiSrc': path.resolve(__dirname, '../redisinsight/api/src/') + // }, + + plugins: [ + new BundleAnalyzerPlugin({ + analyzerMode: process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', + openAnalyzer: process.env.OPEN_ANALYZER === 'true', + }), + + new webpack.EnvironmentPlugin({ + NODE_ENV: 'production', + DEBUG_PROD: false, + START_MINIMIZED: false, + APP_ENV: 'electron', + SERVER_TLS: true, + SERVER_TLS_CERT: process.env.SERVER_TLS_CERT || '', + SERVER_TLS_KEY: process.env.SERVER_TLS_KEY || '', + BUILD_TYPE: 'ELECTRON', + APP_VERSION: version, + AWS_BUCKET_NAME: 'AWS_BUCKET_NAME' in process.env ? process.env.AWS_BUCKET_NAME : '', + SEGMENT_WRITE_KEY: 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', + }), + ], + + /** + * Disables webpack processing of __dirname and __filename. + * If you run the bundle in node.js it falls back to these values of node.js. + * https://github.com/webpack/webpack/issues/2010 + */ + node: { + __dirname: false, + __filename: false, + }, +}); diff --git a/configs/webpack.config.main.stage.babel.js b/configs/webpack.config.main.stage.babel.js new file mode 100644 index 0000000000..76e6d43ab7 --- /dev/null +++ b/configs/webpack.config.main.stage.babel.js @@ -0,0 +1,32 @@ +import webpack from 'webpack'; +import { merge } from 'webpack-merge'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import mainProdConfig from './webpack.config.main.prod.babel'; +import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; +import { version } from '../redisinsight/package.json'; + +DeleteSourceMaps(); + +export default merge(mainProdConfig, { + plugins: [ + new BundleAnalyzerPlugin({ + analyzerMode: + process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', + openAnalyzer: process.env.OPEN_ANALYZER === 'true', + }), + + new webpack.EnvironmentPlugin({ + NODE_ENV: 'staging', + DEBUG_PROD: false, + START_MINIMIZED: false, + APP_ENV: 'electron', + SERVER_TLS: true, + SERVER_TLS_CERT: process.env.SERVER_TLS_CERT || '', + SERVER_TLS_KEY: process.env.SERVER_TLS_KEY || '', + BUILD_TYPE: 'ELECTRON', + APP_VERSION: version, + AWS_BUCKET_NAME: 'AWS_BUCKET_NAME' in process.env ? process.env.AWS_BUCKET_NAME : '', + SEGMENT_WRITE_KEY: 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', + }), + ], +}); diff --git a/configs/webpack.config.renderer.dev.babel.js b/configs/webpack.config.renderer.dev.babel.js new file mode 100644 index 0000000000..8b7c7f2497 --- /dev/null +++ b/configs/webpack.config.renderer.dev.babel.js @@ -0,0 +1,270 @@ +import path from 'path'; +import webpack from 'webpack'; +import { merge } from 'webpack-merge'; +import { spawn } from 'child_process'; +import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; +import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; +import baseConfig from './webpack.config.base'; +import { version } from '../redisinsight/package.json'; + +const port = process.env.PORT || 1212; +const publicPath = `http://localhost:${port}/dist`; +const dllDir = path.join(__dirname, '../dll'); +const manifest = path.resolve(dllDir, 'renderer.json'); +const requiredByDLLConfig = module.parent.filename.includes('webpack.config.renderer.dev.dll'); + +function employCache(loaders) { + return ['cache-loader'].concat(loaders); +} + +export default merge(baseConfig, { + devtool: 'inline-source-map', + + mode: 'development', + + target: 'electron-renderer', + + entry: [ + 'core-js', + 'regenerator-runtime/runtime', + // require.resolve('../redisinsight/main.renderer.ts'), + require.resolve('../redisinsight/ui/indexElectron.tsx'), + ], + + output: { + publicPath: `http://localhost:${port}/dist/`, + filename: 'renderer.dev.js', + }, + + resolve: { + alias: { + apiSrc: path.resolve(__dirname, '../redisinsight/api/src'), + }, + }, + + module: { + rules: [ + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve('babel-loader'), + options: { + plugins: [require.resolve('react-refresh/babel')].filter(Boolean), + }, + }, + ], + }, + { + test: /\.module\.s(a|c)ss$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + options: { + modules: true, + sourceMap: true, + }, + }, + { + loader: 'sass-loader', + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.s(a|c)ss$/, + exclude: [/\.module.(s(a|c)ss)$/, /\.lazy\.s(a|c)ss$/i], + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + options: { + sourceMap: true, + }, + }, + ], + }, + // SASS lazy support + { + test: /\.lazy\.s(a|c)ss$/i, + use: employCache([ + { + loader: 'style-loader', + options: { injectType: 'lazySingletonStyleTag' }, + }, + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + }, + ]), + exclude: /node_modules/, + }, + // WOFF Font + { + test: /\.woff(\?v=\d+\.\d+\.\d+)?$/, + use: { + loader: 'url-loader', + options: { + limit: 10000, + mimetype: 'application/font-woff', + }, + }, + }, + // WOFF2 Font + { + test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // TTF Font + { + test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, + exclude: /codicon\.ttf(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // TTF codicon font + { + test: /codicon\.ttf(\?v=\d+\.\d+\.\d+)?$/, + use: 'url-loader', + }, + // OTF Font + { + test: /\.otf(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // EOT Font + { + test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, + use: 'file-loader', + }, + // SVG Font + { + test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, + use: { + loader: 'url-loader', + options: { + limit: 10000, + mimetype: 'image/svg+xml', + }, + }, + }, + // Common Image Formats + { + test: /\.(?:ico|gif|png|jpg|jpeg|webp)$/, + use: 'url-loader', + }, + ], + }, + plugins: [ + requiredByDLLConfig + ? null + : new webpack.DllReferencePlugin({ + context: path.join(__dirname, '../dll'), + manifest: require(manifest), + sourceType: 'var', + }), + + new webpack.NoEmitOnErrorsPlugin(), + + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + APP_ENV: 'electron', + API_PREFIX: 'api', + BASE_API_URL: 'http://localhost', + RESOURCES_BASE_URL: 'http://localhost', + SCAN_COUNT_DEFAULT: '500', + BUILD_TYPE: 'ELECTRON', + APP_VERSION: version, + SEGMENT_WRITE_KEY: + 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', + }), + + new webpack.LoaderOptionsPlugin({ + debug: true, + }), + + new ReactRefreshWebpackPlugin(), + + new MonacoWebpackPlugin({ languages: [], features: ['!rename'] }), + ], + + node: { + __dirname: false, + __filename: false, + }, + + devServer: { + port, + publicPath, + compress: true, + noInfo: false, + stats: 'errors-only', + inline: true, + lazy: false, + hot: true, + headers: { 'Access-Control-Allow-Origin': '*' }, + contentBase: path.join(__dirname, 'dist'), + watchOptions: { + aggregateTimeout: 300, + ignored: /node_modules/, + poll: 100, + }, + historyApiFallback: { + verbose: true, + disableDotRule: false, + }, + before() { + console.log('Starting Main Process...'); + spawn('npm', ['run', 'start:main'], { + shell: true, + env: process.env, + stdio: 'inherit', + }) + .on('close', (code) => process.exit(code)) + .on('error', (spawnError) => console.error(spawnError)); + }, + }, +}); diff --git a/configs/webpack.config.renderer.dev.dll.babel.js b/configs/webpack.config.renderer.dev.dll.babel.js new file mode 100644 index 0000000000..b5ac030987 --- /dev/null +++ b/configs/webpack.config.renderer.dev.dll.babel.js @@ -0,0 +1,69 @@ +import webpack from 'webpack'; +import path from 'path'; +import { merge } from 'webpack-merge'; +import baseConfig from './webpack.config.base'; +import { dependencies } from '../package.json'; +import { dependencies as dependenciesApi } from '../redisinsight/package.json'; + +console.log('dependenciesApi', dependenciesApi); + +const dist = path.join(__dirname, '../dll'); + +export default merge(baseConfig, { + context: path.join(__dirname, '..'), + + devtool: 'eval', + + mode: 'development', + + target: 'electron-renderer', + + externals: ['fsevents', 'crypto-browserify', 'hiredis'], + + /** + * Use `module` from `webpack.config.renderer.dev.js` + */ + module: require('./webpack.config.renderer.dev.babel').default.module, + + entry: { + renderer: [...Object.keys(dependencies || {}), ...Object.keys(dependenciesApi || {})], + }, + + output: { + library: 'renderer', + path: dist, + filename: '[name].dev.dll.js', + libraryTarget: 'var', + }, + + stats: 'errors-only', + + plugins: [ + new webpack.DllPlugin({ + path: path.join(dist, '[name].json'), + name: '[name]', + }), + + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + APP_ENV: 'electron', + API_PREFIX: 'api', + BASE_API_URL: 'http://localhost', + RESOURCES_BASE_URL: 'http://localhost', + SCAN_COUNT_DEFAULT: '500', + SEGMENT_WRITE_KEY: + 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', + }), + + new webpack.LoaderOptionsPlugin({ + debug: true, + options: { + context: path.join(__dirname, '..'), + output: { + path: path.join(__dirname, '../dll'), + }, + }, + }), + + ], +}); diff --git a/configs/webpack.config.renderer.prod.babel.js b/configs/webpack.config.renderer.prod.babel.js new file mode 100644 index 0000000000..d1f05afcb8 --- /dev/null +++ b/configs/webpack.config.renderer.prod.babel.js @@ -0,0 +1,220 @@ +import path from 'path'; +import webpack from 'webpack'; +import MiniCssExtractPlugin from 'mini-css-extract-plugin'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; +import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; +import { merge } from 'webpack-merge'; +import TerserPlugin from 'terser-webpack-plugin'; +import baseConfig from './webpack.config.base'; +import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; + +DeleteSourceMaps(); + +const devtoolsConfig = + process.env.DEBUG_PROD === 'true' + ? { + devtool: 'source-map', + } + : {}; + +export default merge(baseConfig, { + ...devtoolsConfig, + + mode: 'production', + + target: 'electron-renderer', + + entry: [ + 'core-js', + 'regenerator-runtime/runtime', + // path.join(__dirname, '../redisinsight/main.renderer.ts'), + path.join(__dirname, '../redisinsight/ui/indexElectron.tsx'), + ], + + output: { + path: path.join(__dirname, '../redisinsight/dist'), + publicPath: './dist/', + filename: 'renderer.prod.js', + }, + + module: { + rules: [ + { + test: /\.module\.s(a|c)ss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + { + loader: 'css-loader', + options: { + modules: true, + sourceMap: false, + }, + }, + { + loader: 'sass-loader', + options: { + sourceMap: false, + }, + }, + ], + }, + { + test: /\.s(a|c)ss$/, + exclude: [/\.module.(s(a|c)ss)$/, /\.lazy\.s(a|c)ss$/i], + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + options: { + sourceMap: false, + }, + }, + ], + }, + // SASS lazy support + { + test: /\.lazy\.s(a|c)ss$/i, + use: [ + { + loader: 'style-loader', + options: { injectType: 'lazySingletonStyleTag' }, + }, + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + }, + ], + exclude: /node_modules/, + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + // WOFF Font + { + test: /\.woff(\?v=\d+\.\d+\.\d+)?$/, + use: { + loader: 'url-loader', + options: { + limit: 10000, + mimetype: 'application/font-woff', + }, + }, + }, + // WOFF2 Font + { + test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // TTF Font + { + test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, + exclude: /codicon\.ttf(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // TTF codicon font + { + test: /codicon\.ttf(\?v=\d+\.\d+\.\d+)?$/, + use: 'url-loader', + }, + // OTF Font + { + test: /\.otf(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // EOT Font + { + test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, + use: 'file-loader', + }, + // SVG Font + { + test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, + use: { + loader: 'url-loader', + options: { + limit: 10000, + mimetype: 'image/svg+xml', + }, + }, + }, + // Common Image Formats + { + test: /\.(?:ico|gif|png|jpg|jpeg|webp)$/, + use: 'url-loader', + }, + ], + }, + + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + parallel: true, + }), + new CssMinimizerPlugin(), + ], + }, + + plugins: [ + new MonacoWebpackPlugin({ languages: [], features: ['!rename'] }), + + new webpack.EnvironmentPlugin({ + NODE_ENV: 'production', + DEBUG_PROD: false, + API_PREFIX: 'api', + BASE_API_URL: process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY ? 'https://localhost' : 'http://localhost', + RESOURCES_BASE_URL: process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY ? 'https://localhost' : 'http://localhost', + APP_ENV: 'electron', + SCAN_COUNT_DEFAULT: '500', + SEGMENT_WRITE_KEY: + 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', + }), + + new MiniCssExtractPlugin({ + filename: 'style.css', + }), + + new BundleAnalyzerPlugin({ + analyzerMode: process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', + openAnalyzer: process.env.OPEN_ANALYZER === 'true', + }), + ], +}); diff --git a/configs/webpack.config.renderer.stage.babel.js b/configs/webpack.config.renderer.stage.babel.js new file mode 100644 index 0000000000..cc2d3bf051 --- /dev/null +++ b/configs/webpack.config.renderer.stage.babel.js @@ -0,0 +1,27 @@ +import webpack from 'webpack'; +import { merge } from 'webpack-merge'; +import baseConfig from './webpack.config.base'; +import rendererProdConfig from './webpack.config.renderer.prod.babel'; +import DeleteSourceMaps from '../scripts/DeleteSourceMaps'; + +DeleteSourceMaps(); + +export default merge(baseConfig, { + ...rendererProdConfig, + + plugins: [ + ...rendererProdConfig.plugins, + + new webpack.EnvironmentPlugin({ + NODE_ENV: 'staging', + DEBUG_PROD: false, + API_PREFIX: 'api', + BASE_API_URL: process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY ? 'https://localhost' : 'http://localhost', + RESOURCES_BASE_URL: process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY ? 'https://localhost' : 'http://localhost', + APP_ENV: 'electron', + SCAN_COUNT_DEFAULT: '500', + SEGMENT_WRITE_KEY: + 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', + }), + ], +}); diff --git a/configs/webpack.config.web.common.babel.js b/configs/webpack.config.web.common.babel.js new file mode 100644 index 0000000000..936bc40509 --- /dev/null +++ b/configs/webpack.config.web.common.babel.js @@ -0,0 +1,116 @@ +/** + * Base webpack config used across other specific configs for web + */ +import path from 'path'; +import webpack from 'webpack'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; +import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; +import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; +import { dependencies as externals } from '../redisinsight/package.json'; +import { dependencies as externalsApi } from '../redisinsight/api/package.json'; + +export default { + target: 'web', + + externals: [...Object.keys(externals || {}), ...Object.keys(externalsApi || {})], + + module: { + rules: [ + { + test: /\.(js|jsx|ts|tsx)?$/, + // exclude: /node_modules/, + include: [path.resolve(__dirname, '../redisinsight/ui')], + exclude: [ + /node_modules/, + path.resolve(__dirname, '../menu.ts'), + path.resolve(__dirname, 'menu.ts'), + path.resolve(__dirname, '../Menu.ts'), + path.resolve(__dirname, 'Menu.ts'), + path.resolve(__dirname, '../redisinsight/main.dev.ts'), + path.resolve(__dirname, '../redisinsight/api'), + ], + use: { + loader: 'babel-loader', + options: { + cacheDirectory: true, + }, + }, + }, + // SVG Font + { + test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, + use: { + loader: 'url-loader', + options: { + limit: 10000, + mimetype: 'image/svg+xml', + }, + }, + }, + // Common Image Formats + { + test: /\.(?:ico|gif|png|jpg|jpeg|webp)$/, + use: 'url-loader', + }, + ], + }, + + // context: path.resolve(__dirname, '../redisinsight/api/src'), + context: path.resolve(__dirname, '../redisinsight/ui'), + + /** + * Determine the array of extensions that should be used to resolve modules. + */ + resolve: { + extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], + plugins: [ + new TsconfigPathsPlugin({ + configFile: path.join(__dirname, '..', 'tsconfig.json'), + }), + ], + fallback: { + os: false, + }, + + modules: ['node_modules', path.join(__dirname, '../node_modules')], + }, + + plugins: [ + new HtmlWebpackPlugin({ template: 'index.html.ejs' }), + + new MonacoWebpackPlugin({ languages: [], features: ['!rename'] }), + + new webpack.IgnorePlugin({ + checkResource(resource) { + const lazyImports = [ + '@nestjs/microservices', + '@nestjs/platform-express', + 'pnpapi', + 'stream', + 'os', + 'os-browserify', + 'cache-manager', + 'class-validator', + 'class-transformer', + 'fastify-static', + 'fastify-swagger', + 'reflect-metadata', + 'swagger-ui-express', + 'class-transformer/storage', + '@nestjs/websockets', + '@nestjs/microservices/microservices-module', + '@nestjs/websockets/socket-module', + ]; + if (!lazyImports.includes(resource)) { + return false; + } + try { + require.resolve(resource); + } catch (err) { + return true; + } + return false; + }, + }), + ], +}; diff --git a/configs/webpack.config.web.dev.babel.js b/configs/webpack.config.web.dev.babel.js new file mode 100644 index 0000000000..7a128ed947 --- /dev/null +++ b/configs/webpack.config.web.dev.babel.js @@ -0,0 +1,204 @@ +/** + * Build config for development electron renderer process that uses + * Hot-Module-Replacement + * + * https://webpack.js.org/concepts/hot-module-replacement/ + */ + +import webpack from 'webpack'; +import { merge } from 'webpack-merge'; +import commonConfig from './webpack.config.web.common.babel'; + +function employCache(loaders) { + return ['cache-loader'].concat(loaders); +} + +export default merge(commonConfig, { + target: 'web', + + mode: 'development', + + devtool: 'source-map', + + entry: [ + 'regenerator-runtime/runtime', + 'webpack-dev-server/client?http://localhost:8080', + 'webpack/hot/only-dev-server', + require.resolve('../redisinsight/ui/index.tsx'), + ], + + module: { + rules: [ + { + test: /\.module\.s(a|c)ss$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + options: { + modules: true, + sourceMap: true, + }, + }, + { + loader: 'sass-loader', + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.s(a|c)ss$/, + exclude: [/\.module.(s(a|c)ss)$/, /\.lazy\.s(a|c)ss$/i], + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.lazy\.s(a|c)ss$/i, + use: employCache([ + { + loader: 'style-loader', + options: { injectType: 'lazySingletonStyleTag' }, + }, + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + }, + ]), + exclude: /node_modules/, + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + // WOFF Font + { + test: /\.woff(\?v=\d+\.\d+\.\d+)?$/, + use: { + loader: 'url-loader', + options: { + limit: 10000, + mimetype: 'application/font-woff', + }, + }, + }, + // WOFF2 Font + { + test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // TTF Font + { + test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, + exclude: /codicon\.ttf(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // TTF codicon font + { + test: /codicon\.ttf(\?v=\d+\.\d+\.\d+)?$/, + use: 'url-loader', + }, + // OTF Font + { + test: /\.otf(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // EOT Font + { + test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, + use: 'file-loader', + }, + ], + }, + + devServer: { + port: 8080, + hot: true, // enable HMR on the server + historyApiFallback: true, + }, + plugins: [ + new webpack.HotModuleReplacementPlugin({ + multiStep: true, + }), + + new webpack.NoEmitOnErrorsPlugin(), + + /** + * Create global constants which can be configured at compile time. + * + * Useful for allowing different behavior between development builds and + * release builds + * + * NODE_ENV should be production so that modules do not perform certain + * development checks + * + * By default, use 'development' as NODE_ENV. This can be override with + * 'staging', for example, by changing the ENV variables in the npm scripts + */ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + APP_ENV: 'web', + API_PREFIX: 'api', + API_PORT: '5000', + BASE_API_URL: `http://${require('os').hostname()}`, + RESOURCES_BASE_URL: `http://${require('os').hostname()}`, + SCAN_COUNT_DEFAULT: '500', + SEGMENT_WRITE_KEY: + 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', + }), + + new webpack.LoaderOptionsPlugin({ + debug: true, + }), + + new webpack.HotModuleReplacementPlugin(), // enable HMR globally + ], + + externals: { + react: 'React', + }, +}); diff --git a/configs/webpack.config.web.prod.babel.js b/configs/webpack.config.web.prod.babel.js new file mode 100644 index 0000000000..45a17ac15c --- /dev/null +++ b/configs/webpack.config.web.prod.babel.js @@ -0,0 +1,165 @@ +import { merge } from 'webpack-merge'; +import { resolve } from 'path'; +import webpack from 'webpack'; +import TerserPlugin from 'terser-webpack-plugin'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; +import MiniCssExtractPlugin from 'mini-css-extract-plugin'; +import commonConfig from './webpack.config.web.common.babel'; +import DeleteDistWeb from '../scripts/DeleteDistWeb'; + +DeleteDistWeb(); + +const devtoolsConfig = + process.env.DEBUG_PROD === 'true' + ? { + devtool: 'source-map', + } + : {}; + +export default merge(commonConfig, { + ...devtoolsConfig, + + mode: 'production', + target: 'web', + entry: ['regenerator-runtime/runtime', './index.tsx'], + output: { + filename: 'js/bundle.[fullhash].min.js', + path: resolve(__dirname, '../redisinsight/ui/dist'), + publicPath: '/', + }, + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + parallel: true, + }), + new CssMinimizerPlugin(), + ], + }, + plugins: [ + new MiniCssExtractPlugin({ + filename: '[name].[fullhash].css', + chunkFilename: '[id].[fullhash].css', + }), + + new webpack.EnvironmentPlugin({ + NODE_ENV: 'production', + APP_ENV: 'web', + API_PORT: '5000', + API_PREFIX: '', + BASE_API_URL: 'api/', + RESOURCES_BASE_URL: + process.env.SERVER_TLS_CERT && process.env.SERVER_TLS_KEY ? 'https://localhost' : 'http://localhost', + SCAN_COUNT_DEFAULT: '500', + SEGMENT_WRITE_KEY: + 'SEGMENT_WRITE_KEY' in process.env ? process.env.SEGMENT_WRITE_KEY : 'SOURCE_WRITE_KEY', + }), + + new BundleAnalyzerPlugin({ + analyzerMode: process.env.OPEN_ANALYZER === 'true' ? 'server' : 'disabled', + openAnalyzer: process.env.OPEN_ANALYZER === 'true', + }), + ], + module: { + rules: [ + { + test: /\.module\.s(a|c)ss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + { + loader: 'css-loader', + options: { + modules: true, + sourceMap: false, + }, + }, + { + loader: 'sass-loader', + options: { + sourceMap: false, + }, + }, + ], + }, + { + test: /\.s(a|c)ss$/, + exclude: [/\.module.(s(a|c)ss)$/, /\.lazy\.s(a|c)ss$/i], + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + options: { + sourceMap: false, + }, + }, + ], + }, + { + test: /\.lazy\.s(a|c)ss$/i, + use: [ + { + loader: 'style-loader', + options: { injectType: 'lazySingletonStyleTag' }, + }, + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + }, + ], + exclude: /node_modules/, + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + // TTF Font + { + test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, + exclude: /codicon\.ttf(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + // TTF codicon font + { + test: /codicon\.ttf(\?v=\d+\.\d+\.\d+)?$/, + use: 'url-loader', + }, + // OTF Font + { + test: /\.otf(\?v=\d+\.\d+\.\d+)?$/, + use: [ + { + loader: 'file-loader', + options: { + name: '[hash]-[name].[ext]', + outputPath: 'static', + publicPath: 'static', + }, + }, + ], + }, + ], + }, + externals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, +}); diff --git a/docker-entry.sh b/docker-entry.sh new file mode 100644 index 0000000000..39eafe34ba --- /dev/null +++ b/docker-entry.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Entry point for distributable docker image +# This script does some setup required for bootstrapping the container +# and then runs whatever is passed as arguments to this script. +# If the CMD directive is specified in the Dockerfile, those commands +# are passed to this script. This can be overridden by the user in the +# `docker run` +set -e + +# Set up mDNS functionality, to play well with Redis Enterprise +# clusters on the network. Also, run it as a non-root user. +# https://linux.die.net/man/8/avahi-daemon +avahi-daemon --daemonize --no-drop-root + +# Launching system's secret storage +eval "$(dbus-launch --sh-syntax)" +mkdir -p ~/.cache +mkdir -p ~/.local/share/keyrings # where the automatic keyring is created +eval "$(echo "$GNOME_KEYRING_PASS" | gnome-keyring-daemon --unlock)" +sleep 1 +eval "$(echo "$GNOME_KEYRING_PASS" | gnome-keyring-daemon --start)" + +# Run the application's entry script with the exec command so it catches SIGTERM properly +exec "$@" diff --git a/electron-builder.json b/electron-builder.json new file mode 100644 index 0000000000..b7b893e969 --- /dev/null +++ b/electron-builder.json @@ -0,0 +1,70 @@ +{ + "productName": "RedisInsight-preview", + "appId": "org.RedisLabs.RedisInsight-V2", + "copyright": "Copyright © 2021 Redis Ltd.", + "files": [ + "dist/", + "node_modules/", + "index.html", + "main.prod.js", + "main.prod.js.map", + "package.json" + ], + "afterSign": "electron-builder-notarize", + "artifactName": "RedisInsight.${ext}", + "compression": "normal", + "mac": { + "target": ["dmg", "zip"], + "type": "distribution", + "hardenedRuntime": true, + "darkModeSupport": true, + "entitlements": "resources/entitlements.mac.plist", + "entitlementsInherit": "resources/entitlements.mac.plist", + "gatekeeperAssess": false + }, + "dmg": { + "artifactName": "RedisInsight-${os}-x64.${ext}", + "contents": [ + { + "x": 130, + "y": 220 + }, + { + "x": 410, + "y": 220, + "type": "link", + "path": "/Applications" + } + ] + }, + "win": { + "target": ["nsis"], + "artifactName": "RedisInsight-${os}-installer.${ext}" + }, + "linux": { + "icon": "./resources/icons", + "target": ["deb", "AppImage"], + "synopsis": "Redis GUI by Redis Ltd.", + "category": "Development", + "artifactName": "RedisInsight-${os}.${ext}", + "desktop": { + "Name": "RedisInsight", + "Type": "Application", + "Comment": "Redis GUI by Redis Ltd", + "Terminal": "true" + } + }, + "directories": { + "app": "redisinsight", + "buildResources": "resources", + "output": "release" + }, + "extraResources": [ + "./resources/**", + { + "from": "./redisinsight/api/src/static", + "to": "static", + "filter": ["**/*"] + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..63969e6921 --- /dev/null +++ b/package.json @@ -0,0 +1,256 @@ +{ + "name": "redisinsight", + "productName": "RedisInsight", + "description": "RedisInsight", + "license": "SSPL", + "private": true, + "scripts": { + "build": "cross-env NODE_ENV=development concurrently \"yarn build:main\" \"yarn build:renderer\"", + "build:stage": "cross-env NODE_ENV=staging concurrently \"yarn build:api:stage && yarn build:main:stage\" \"yarn build:renderer:stage\"", + "build:prod": "cross-env NODE_ENV=production concurrently \"yarn build:api && yarn build:main\" \"yarn build:renderer\"", + "build:api": "yarn --cwd redisinsight/api/ build:prod", + "build:api:stage": "yarn --cwd redisinsight/api/ build:stage", + "build:main": "webpack --config ./configs/webpack.config.main.prod.babel.js", + "build:main:stage": "webpack --config ./configs/webpack.config.main.stage.babel.js", + "build:web": "webpack --config ./configs/webpack.config.web.prod.babel.js", + "build:statics": "sh ./scripts/build-statics.sh", + "build:statics:win": "./scripts/build-statics.cmd", + "build:renderer": "webpack --config ./configs/webpack.config.renderer.prod.babel.js", + "build:renderer:stage": "webpack --config ./configs/webpack.config.renderer.stage.babel.js", + "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir redisinsight/ui", + "lint": "eslint . --ext .js,.jsx,.ts,.tsx", + "lint:ui": "eslint ./redisinsight/ui --ext .js,.jsx,.ts,.tsx", + "lint:api": "yarn --cwd redisinsight/api lint", + "lint:e2e": "yarn --cwd tests/e2e lint", + "package": "yarn package:dev", + "package:prod": "yarn build:prod && electron-builder build -p never", + "package:stage": "yarn build:stage && electron-builder build -p never", + "package:dev": "yarn build && cross-env DEBUG=electron-builder electron-builder build -p never", + "package:win": "yarn build:prod && electron-builder build --win --x64 -p never", + "package:mac": "yarn build:prod && electron-builder build --mac -p never", + "package:mac:arm": "yarn build:prod && electron-builder build --mac --arm64 -p never", + "package:linux": "yarn build:prod && electron-builder build --linux -p never", + "postinstall": "skip-postinstall || (electron-builder install-app-deps && yarn webpack --config ./configs/webpack.config.renderer.dev.dll.babel.js && opencollective-postinstall && yarn-deduplicate yarn.lock)", + "start": "cross-env NODE_ENV=development webpack serve --config ./configs/webpack.config.renderer.dev.babel.js", + "start:main": "cross-env NODE_ENV=development electron -r ./scripts/BabelRegister redisinsight/main.dev.ts", + "start:web": "webpack serve --config ./configs/webpack.config.web.dev.babel.js", + "test": "jest ./redisinsight/ui -w 1", + "test:watch": "jest ./redisinsight/ui --watch -w 1", + "test:cov": "jest ./redisinsight/ui --coverage -w 1" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint --cache" + ] + }, + "build": { + "extends": "./electron-builder.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/RedisLabs/redisinsight-v2.git" + }, + "author": { + "name": "Redis Ltd.", + "email": "support@redis.com", + "url": "https://redis.com/redis-enterprise/redis-insight" + }, + "bugs": { + "url": "https://github.com/RedisLabs/redisinsight-v2/issues" + }, + "keywords": [ + "redisinsight", + "redis", + "electron", + "react", + "nest", + "typescript", + "sass", + "webpack" + ], + "homepage": "https://github.com/RedisLabs/redisinsight-v2#readme", + "jest": { + "testURL": "http://localhost/", + "moduleNameMapper": { + "\\.(jpg|jpeg|png|ico|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/redisinsight/__mocks__/fileMock.js", + "\\.(css|less|sass|scss)$": "identity-obj-proxy", + "uiSrc/(.*)": "/redisinsight/ui/src/$1", + "monaco-editor": "/redisinsight/__mocks__/monacoMock.js" + }, + "setupFilesAfterEnv": [ + "/redisinsight/ui/src/setup-tests.ts" + ], + "moduleDirectories": [ + "node_modules", + "redisinsight/node_modules" + ], + "moduleFileExtensions": [ + "js", + "jsx", + "ts", + "tsx", + "json" + ], + "transformIgnorePatterns": [ + "node_modules/(?!(monaco-editor|react-monaco-editor)/)" + ] + }, + "devDependencies": { + "@babel/core": "^7.12.9", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-decorators": "^7.12.1", + "@babel/plugin-proposal-do-expressions": "^7.12.1", + "@babel/plugin-proposal-export-default-from": "^7.12.1", + "@babel/plugin-proposal-export-namespace-from": "^7.12.1", + "@babel/plugin-proposal-function-bind": "^7.12.1", + "@babel/plugin-proposal-function-sent": "^7.12.1", + "@babel/plugin-proposal-json-strings": "^7.12.1", + "@babel/plugin-proposal-logical-assignment-operators": "^7.12.1", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", + "@babel/plugin-proposal-optional-chaining": "^7.12.7", + "@babel/plugin-proposal-pipeline-operator": "^7.12.1", + "@babel/plugin-proposal-throw-expressions": "^7.12.1", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-transform-react-constant-elements": "^7.12.1", + "@babel/plugin-transform-react-inline-elements": "^7.12.1", + "@babel/plugin-transform-runtime": "^7.12.1", + "@babel/preset-env": "^7.12.7", + "@babel/preset-react": "^7.12.7", + "@babel/preset-typescript": "^7.12.7", + "@babel/register": "^7.12.1", + "@nestjs/cli": "^7.0.0", + "@nestjs/schematics": "^7.0.0", + "@nestjs/testing": "^7.0.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", + "@teamsupercell/typings-for-css-modules-loader": "^2.4.0", + "@testing-library/jest-dom": "^5.11.6", + "@testing-library/react": "^11.2.2", + "@testing-library/react-hooks": "^5.0.3", + "@types/axios": "^0.14.0", + "@types/classnames": "^2.2.11", + "@types/date-fns": "^2.6.0", + "@types/detect-port": "^1.3.0", + "@types/electron-store": "^3.2.0", + "@types/express": "^4.17.3", + "@types/html-entities": "^1.3.4", + "@types/ioredis": "^4.26.0", + "@types/jest": "^26.0.15", + "@types/lodash": "^4.14.171", + "@types/node": "14.14.10", + "@types/react-dom": "^17.0.0", + "@types/react-monaco-editor": "^0.16.0", + "@types/react-redux": "^7.1.12", + "@types/react-router-dom": "^5.1.6", + "@types/react-virtualized": "^9.21.10", + "@types/redux-mock-store": "^1.0.2", + "@types/segment-analytics": "^0.0.34", + "@types/supertest": "^2.0.8", + "@types/webpack-env": "^1.15.2", + "@typescript-eslint/eslint-plugin": "^4.8.1", + "@typescript-eslint/parser": "^4.8.1", + "babel-eslint": "^10.1.0", + "babel-jest": "^26.1.0", + "babel-loader": "^8.2.2", + "babel-plugin-dev-expression": "^0.2.2", + "babel-plugin-parameter-decorator": "^1.0.16", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24", + "cache-loader": "^4.1.0", + "concurrently": "^5.3.0", + "core-js": "^3.6.5", + "cross-env": "^7.0.2", + "css-loader": "^5.0.1", + "css-minimizer-webpack-plugin": "^1.2.0", + "electron": "^15.3.1", + "electron-builder": "^22.14.5", + "electron-builder-notarize": "^1.2.0", + "electron-debug": "^3.1.0", + "electron-devtools-installer": "^3.2.0", + "electron-rebuild": "^2.3.2", + "eslint": "^7.5.0", + "eslint-config-airbnb": "^18.2.1", + "eslint-config-airbnb-typescript": "^12.0.0", + "eslint-import-resolver-webpack": "0.13.0", + "eslint-plugin-compat": "^3.8.0", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-jest": "^24.1.3", + "eslint-plugin-jsx-a11y": "6.4.1", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-react": "^7.20.6", + "eslint-plugin-react-hooks": "^4.0.8", + "eslint-plugin-sonarjs": "^0.10.0", + "file-loader": "^6.0.0", + "html-webpack-plugin": "^4.5.0", + "husky": "^4.2.5", + "identity-obj-proxy": "^3.0.0", + "ioredis-mock": "^5.5.4", + "jest": "^26.1.0", + "jest-when": "^3.2.1", + "lint-staged": "^10.2.11", + "mini-css-extract-plugin": "^1.3.1", + "monaco-editor-webpack-plugin": "^6.0.0", + "node-sass": "^5.0.0", + "opencollective-postinstall": "^2.0.3", + "react-hot-loader": "^4.13.0", + "react-refresh": "^0.9.0", + "react-test-renderer": "^17.0.1", + "redux-mock-store": "^1.5.4", + "regenerator-runtime": "^0.13.5", + "rimraf": "^3.0.2", + "sass-loader": "^10.1.0", + "skip-postinstall": "^1.0.0", + "source-map-support": "^0.5.19", + "style-loader": "^2.0.0", + "supertest": "^4.0.2", + "terser-webpack-plugin": "^5.0.3", + "ts-jest": "26.1.0", + "ts-loader": "^6.2.1", + "ts-mockito": "^2.6.1", + "ts-node": "^8.6.2", + "tsconfig-paths": "^3.9.0", + "tsconfig-paths-webpack-plugin": "^3.3.0", + "typescript": "^4.0.5", + "url-loader": "^4.1.0", + "webpack": "^5.5.1", + "webpack-bundle-analyzer": "^4.1.0", + "webpack-cli": "^4.3.0", + "webpack-dev-server": "^3.11.0", + "webpack-merge": "^5.4.0", + "yarn-deduplicate": "^3.1.0" + }, + "dependencies": { + "@elastic/datemath": "^5.0.3", + "@elastic/eui": "36.0.0", + "@reduxjs/toolkit": "^1.6.2", + "axios": "^0.24.0", + "classnames": "^2.3.1", + "connection-string": "^4.3.2", + "date-fns": "^2.16.1", + "detect-port": "^1.3.0", + "electron-context-menu": "^3.1.0", + "electron-log": "^4.2.4", + "electron-store": "^8.0.0", + "electron-updater": "4.6.1", + "formik": "^2.2.9", + "html-entities": "^2.3.2", + "html-react-parser": "^1.2.4", + "lodash": "^4.17.21", + "react": "^17.0.1", + "react-contenteditable": "^3.3.5", + "react-dom": "^17.0.1", + "react-hotkeys-hook": "^3.3.1", + "react-monaco-editor": "^0.44.0", + "react-redux": "^7.2.2", + "react-jsx-parser": "^1.28.4", + "react-router-dom": "^5.2.0", + "react-virtualized": "^9.22.2" + }, + "devEngines": { + "node": ">=14.x <16", + "npm": ">=6.x", + "yarn": ">=1.21.3" + }, + "husky": { + "hooks": {} + } +} diff --git a/redisinsight/__mocks__/fileMock.js b/redisinsight/__mocks__/fileMock.js new file mode 100644 index 0000000000..602eb23ee2 --- /dev/null +++ b/redisinsight/__mocks__/fileMock.js @@ -0,0 +1 @@ +export default 'test-file-stub'; diff --git a/redisinsight/__mocks__/monacoMock.js b/redisinsight/__mocks__/monacoMock.js new file mode 100644 index 0000000000..af886b4467 --- /dev/null +++ b/redisinsight/__mocks__/monacoMock.js @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export default function MonacoEditor(props) { + return
; +} diff --git a/redisinsight/about-panel.ts b/redisinsight/about-panel.ts new file mode 100644 index 0000000000..51b90c8b83 --- /dev/null +++ b/redisinsight/about-panel.ts @@ -0,0 +1,13 @@ +import { app } from 'electron'; +import path from 'path'; + +const ICON_PATH = app.isPackaged + ? path.join(process.resourcesPath, 'resources', 'icon.png') + : path.join(__dirname, '../resources', 'icon.png'); + +export default { + applicationName: 'RedisInsight-preview', + applicationVersion: app.getVersion() || '2.0', + copyright: `Copyright © ${new Date().getFullYear()} Redis Ltd.`, + iconPath: ICON_PATH, +}; diff --git a/redisinsight/api/.dockerignore b/redisinsight/api/.dockerignore new file mode 100644 index 0000000000..c5df3a6fbe --- /dev/null +++ b/redisinsight/api/.dockerignore @@ -0,0 +1,11 @@ +.git +.idea +.vscode +.circleci + +.nyc_output +coverage +node_modules +dist + +test/test-runs/results diff --git a/redisinsight/api/.eslintignore b/redisinsight/api/.eslintignore new file mode 100644 index 0000000000..3c5f3f4f29 --- /dev/null +++ b/redisinsight/api/.eslintignore @@ -0,0 +1,4 @@ +node_modules +dist +test +migration diff --git a/redisinsight/api/.eslintrc.js b/redisinsight/api/.eslintrc.js new file mode 100644 index 0000000000..d3da19987a --- /dev/null +++ b/redisinsight/api/.eslintrc.js @@ -0,0 +1,21 @@ +module.exports = { + root: true, + env: { + node: true, + }, + extends: ['airbnb-typescript/base', 'plugin:sonarjs/recommended'], + plugins: ['@typescript-eslint', 'sonarjs'], + parser: '@typescript-eslint/parser', + rules: { + 'max-len': ['warn', 120], + '@typescript-eslint/return-await': 'off', + "@typescript-eslint/dot-notation": "off", + 'import/prefer-default-export': 'off', // ignore "export default" requirement + 'max-classes-per-file': 'off', + 'class-methods-use-this': 'off', // should be ignored since NestJS allow inheritance without using "this" inside class methods + 'no-await-in-loop': 'off', + }, + parserOptions: { + project: './tsconfig.json', + }, +}; diff --git a/redisinsight/api/.gitignore b/redisinsight/api/.gitignore new file mode 100644 index 0000000000..0fc65df77f --- /dev/null +++ b/redisinsight/api/.gitignore @@ -0,0 +1,41 @@ +# compiled output +/dist +/node_modules +/src/static + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Dev +*.db +/secrets +/ca_certificates +/client_certificates diff --git a/redisinsight/api/.prettierignore b/redisinsight/api/.prettierignore new file mode 100644 index 0000000000..75dde3f4c6 --- /dev/null +++ b/redisinsight/api/.prettierignore @@ -0,0 +1,2 @@ +# Tests +/test diff --git a/redisinsight/api/.prettierrc b/redisinsight/api/.prettierrc new file mode 100644 index 0000000000..dcb72794f5 --- /dev/null +++ b/redisinsight/api/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/redisinsight/api/.yarnclean.prod b/redisinsight/api/.yarnclean.prod new file mode 100644 index 0000000000..7b6d5cdce5 --- /dev/null +++ b/redisinsight/api/.yarnclean.prod @@ -0,0 +1,3 @@ +*.md +*.ts +*.map diff --git a/redisinsight/api/README.md b/redisinsight/api/README.md new file mode 100644 index 0000000000..e3e09c1b52 --- /dev/null +++ b/redisinsight/api/README.md @@ -0,0 +1,63 @@ +# RedisInsight API + +## Description +RedisInsight provides an intuitive and efficient GUI for Redis, allowing you to interact with your databases and manage your data—with built-in support for most popular Redis modules. It provides tools to analyze the memory, profile the performance of your database usage, and guide you toward better Redis usage. + +## Prerequisites + +Make sure you have installed following packages: +* [Node](https://nodejs.org/en/download/) >= 8.0 +* [npm](https://www.npmjs.com/get-npm) >= 5 + +## Dependencies used +* [NestJS](https://nestjs.com/) + +## Getting started + +### Installation + +```bash +$ yarn install +``` + +### Running the app + +```bash +# development +$ yarn start + +# watch mode +$ yarn start:dev + +# production mode +$ yarn start:prod +``` + +### Formatting + +Formatting required before submitting pull request. + +Prints the filenames of files that are different from Prettier formatting +```bash +$ yarn format +``` +### Swagger OpenApi + +The [OpenAPI](https://swagger.io/specification/) specification is a language-agnostic definition format used +to describe RESTful APIs. + +While the application is running, open your browser and navigate to `http://localhost[:]/api/docs`. +You should see the Swagger UI. + +### Test + +```bash +# unit tests +$ yarn test + +# e2e tests +$ yarn test:e2e + +# test coverage +$ yarn test:cov +``` diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts new file mode 100644 index 0000000000..7f33a17f7a --- /dev/null +++ b/redisinsight/api/config/default.ts @@ -0,0 +1,89 @@ +import { join } from 'path'; + +const homedir = join(__dirname, '..'); + +const staticDir = process.env.BUILD_TYPE === 'ELECTRON' && process['resourcesPath'] + ? join(process['resourcesPath'], 'static') + : join(__dirname, '..', 'static'); + +export default { + dir_path: { + homedir, + staticDir, + logs: join(homedir, 'logs'), + defaultPlugins: join(staticDir, 'plugins'), + customPlugins: join(homedir, 'plugins'), + pluginsAssets: join(staticDir, 'resources', 'plugins'), + commands: join(homedir, 'commands'), + caCertificates: join(homedir, 'ca_certificates'), + clientCertificates: join(homedir, 'client_certificates'), + }, + server: { + env: 'development', + port: 5000, + docPrefix: 'api/docs', + globalPrefix: 'api', + customPluginsUri: '/plugins', + staticUri: '/static', + defaultPluginsUri: '/static/plugins', + pluginsAssetsUri: '/static/resources/plugins', + secretStoragePassword: process.env.SECRET_STORAGE_PASSWORD, + tls: process.env.SERVER_TLS ? process.env.SERVER_TLS === 'true' : true, + tlsCert: process.env.SERVER_TLS_CERT, + tlsKey: process.env.SERVER_TLS_KEY, + staticContent: !!process.env.SERVER_STATIC_CONTENT || false, + buildType: process.env.BUILD_TYPE || 'ELECTRON', + appVersion: process.env.APP_VERSION || '2.0.0', + requestTimeout: parseInt(process.env.REQUEST_TIMEOUT, 10) || 10000, + }, + db: { + database: join(homedir, 'redisinsight.db'), + synchronize: process.env.DB_SYNC ? process.env.DB_SYNC === 'true' : false, + migrationsRun: process.env.DB_MIGRATIONS ? process.env.DB_MIGRATIONS === 'true' : true, + }, + redis_cloud: { + url: process.env.REDIS_CLOUD_URL || 'https://qa-api.redislabs.com/v1/', + }, + redis_clients: { + idleSyncInterval: parseInt(process.env.CLIENTS_IDLE_SYNC_INTERVAL, 10) || 1000 * 60 * 60, // 1hr + maxIdleThreshold: parseInt(process.env.CLIENTS_MAX_IDLE_THRESHOLD, 10) || 1000 * 60 * 60, // 1hr + retryTimes: parseInt(process.env.CLIENTS_RETRY_TIMES, 10) || 5, + retryDelay: parseInt(process.env.CLIENTS_RETRY_DELAY, 10) || 500, + maxRetriesPerRequest: parseInt(process.env.CLIENTS_MAX_RETRIES_PER_REQUEST, 10) || 1, + }, + redis_scan: { + countDefault: parseInt(process.env.SCAN_COUNT_DEFAULT, 10) || 200, + countThreshold: parseInt(process.env.SCAN_COUNT_THRESHOLD, 10) || 10000, + }, + modules: { + json: { + sizeThreshold: parseInt(process.env.JSON_SIZE_THRESHOLD, 10) || 1024, + }, + }, + redis_cli: { + unsupportedCommands: JSON.parse(process.env.CLI_UNSUPPORTED_COMMANDS || '[]'), + }, + analytics: { + writeKey: process.env.SEGMENT_WRITE_KEY || 'SOURCE_WRITE_KEY', + }, + logger: { + stdout: process.env.STDOUT_LOGGER ? process.env.STDOUT_LOGGER === 'true' : false, // disabled by default + files: process.env.FILES_LOGGER ? process.env.FILES_LOGGER === 'true' : true, // enabled by default + omitSensitiveData: process.env.LOGGER_OMIT_DATA ? process.env.LOGGER_OMIT_DATA === 'true' : true, + pipelineSummaryLimit: parseInt(process.env.LOGGER_PIPELINE_SUMMARY_LIMIT, 10) || 5, + }, + commands: { + mainUrl: process.env.COMMANDS_MAIN_URL + || 'https://raw.githubusercontent.com/redis/redis-doc/master/commands.json', + redisearchUrl: process.env.COMMANDS_REDISEARCH_URL + || 'https://raw.githubusercontent.com/RediSearch/RediSearch/master/commands.json', + redijsonUrl: process.env.COMMANDS_REDIJSON_URL + || 'https://raw.githubusercontent.com/RedisJSON/RedisJSON/master/commands.json', + redistimeseriesUrl: process.env.COMMANDS_REDISTIMESERIES_URL + || 'https://raw.githubusercontent.com/RedisTimeSeries/RedisTimeSeries/master/src/commands.json', + redisaiUrl: process.env.COMMANDS_REDISAI_URL + || 'https://raw.githubusercontent.com/RedisAI/RedisAI/master/commands.json', + redisgraphUrl: process.env.COMMANDS_REDISGRAPH_URL + || 'https://raw.githubusercontent.com/RedisGraph/RedisGraph/master/commands.json', + }, +}; diff --git a/redisinsight/api/config/development.ts b/redisinsight/api/config/development.ts new file mode 100644 index 0000000000..56ac010fc2 --- /dev/null +++ b/redisinsight/api/config/development.ts @@ -0,0 +1,13 @@ +export default { + server: { + tls: process.env.SERVER_TLS ? process.env.SERVER_TLS === 'true' : false, + }, + db: { + synchronize: process.env.DB_SYNC ? process.env.DB_SYNC === 'true' : true, + migrationsRun: process.env.DB_MIGRATIONS ? process.env.DB_MIGRATIONS === 'true' : false, + }, + logger: { + stdout: process.env.STDOUT_LOGGER ? process.env.STDOUT_LOGGER === 'true' : true, // enabled by default + omitSensitiveData: process.env.LOGGER_OMIT_DATA ? process.env.LOGGER_OMIT_DATA === 'true' : false, + }, +}; diff --git a/redisinsight/api/config/logger.ts b/redisinsight/api/config/logger.ts new file mode 100644 index 0000000000..aeebcd72d3 --- /dev/null +++ b/redisinsight/api/config/logger.ts @@ -0,0 +1,63 @@ +import { transports, format } from 'winston'; +import 'winston-daily-rotate-file'; +import { + utilities as nestWinstonModuleUtilities, + WinstonModuleOptions, +} from 'nest-winston'; +import { join } from 'path'; +import config from 'src/utils/config'; +import { prettyFormat, sensitiveDataFormatter } from 'src/utils/logsFormatter'; + +const PATH_CONFIG = config.get('dir_path'); +const LOGGER_CONFIG = config.get('logger'); + +const transportsConfig = []; + +if (LOGGER_CONFIG.stdout) { + transportsConfig.push( + new transports.Console({ + format: format.combine( + sensitiveDataFormatter({ omitSensitiveData: LOGGER_CONFIG.omitSensitiveData }), + format.timestamp(), + nestWinstonModuleUtilities.format.nestLike(), + ), + }), + ); +} + +if (LOGGER_CONFIG.files) { + transportsConfig.push( + new transports.DailyRotateFile({ + dirname: join(PATH_CONFIG.logs), + datePattern: 'YYYY-MM-DD', + maxSize: '20m', + maxFiles: '7d', + filename: 'redisinsight-errors-%DATE%.log', + level: 'error', + format: format.combine( + sensitiveDataFormatter({ omitSensitiveData: LOGGER_CONFIG.omitSensitiveData }), + prettyFormat, + ), + }), + ); + transportsConfig.push( + new transports.DailyRotateFile({ + dirname: join(PATH_CONFIG.logs), + datePattern: 'YYYY-MM-DD', + maxSize: '20m', + maxFiles: '7d', + filename: 'redisinsight-%DATE%.log', + format: format.combine( + sensitiveDataFormatter({ omitSensitiveData: LOGGER_CONFIG.omitSensitiveData }), + prettyFormat, + ), + }), + ); +} + +const logger: WinstonModuleOptions = { + format: format.errors({ stack: true }), + transports: transportsConfig, +}; + +export default logger; diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts new file mode 100644 index 0000000000..e751abe7da --- /dev/null +++ b/redisinsight/api/config/ormconfig.ts @@ -0,0 +1,31 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { AgreementsEntity } from 'src/modules/core/models/agreements.entity'; +import { CaCertificateEntity } from 'src/modules/core/models/ca-certificate.entity'; +import { ClientCertificateEntity } from 'src/modules/core/models/client-certificate.entity'; +import { DatabaseInstanceEntity } from 'src/modules/core/models/database-instance.entity'; +import { ServerEntity } from 'src/modules/core/models/server.entity'; +import { SettingsEntity } from 'src/modules/core/models/settings.entity'; +import migrations from '../migration'; +import * as config from '../src/utils/config'; + +const dbConfig = config.get('db'); +const ormConfig: TypeOrmModuleOptions = { + type: 'sqlite', + database: dbConfig.database, + synchronize: dbConfig.synchronize, + migrationsRun: dbConfig.migrationsRun, + entities: [ + AgreementsEntity, + CaCertificateEntity, + ClientCertificateEntity, + DatabaseInstanceEntity, + ServerEntity, + SettingsEntity, + ], + migrations, + cli: { + migrationsDir: 'migration', + }, +}; + +export default ormConfig; diff --git a/redisinsight/api/config/production.ts b/redisinsight/api/config/production.ts new file mode 100644 index 0000000000..eb991c0698 --- /dev/null +++ b/redisinsight/api/config/production.ts @@ -0,0 +1,23 @@ +import { join } from 'path'; + +const homedir = join(require('os').homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-v2.0'); + +export default { + dir_path: { + homedir, + logs: join(homedir, 'logs'), + customPlugins: join(homedir, 'plugins'), + commands: join(homedir, 'commands'), + caCertificates: join(homedir, 'ca_certificates'), + clientCertificates: join(homedir, 'client_certificates'), + }, + server: { + env: 'production', + }, + db: { + database: join(homedir, 'redisinsight.db'), + }, + redis_cloud: { + url: process.env.REDIS_CLOUD_URL || 'https://api.redislabs.com/v1/', + }, +}; diff --git a/redisinsight/api/config/staging.ts b/redisinsight/api/config/staging.ts new file mode 100644 index 0000000000..4ac34c06d9 --- /dev/null +++ b/redisinsight/api/config/staging.ts @@ -0,0 +1,24 @@ +import { join } from 'path'; + +const homedir = join(require('os').homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-v2.0-stage'); + +export default { + dir_path: { + homedir, + logs: join(homedir, 'logs'), + customPlugins: join(homedir, 'plugins'), + commands: join(homedir, 'commands'), + caCertificates: join(homedir, 'ca_certificates'), + clientCertificates: join(homedir, 'client_certificates'), + }, + server: { + env: 'staging', + }, + db: { + database: join(homedir, 'redisinsight.db'), + }, + logger: { + stdout: process.env.STDOUT_LOGGER ? process.env.STDOUT_LOGGER === 'true' : true, // enabled by default + omitSensitiveData: process.env.LOGGER_OMIT_DATA ? process.env.LOGGER_OMIT_DATA === 'true' : false, + }, +}; diff --git a/redisinsight/api/config/swagger.ts b/redisinsight/api/config/swagger.ts new file mode 100644 index 0000000000..4c4f0f7c88 --- /dev/null +++ b/redisinsight/api/config/swagger.ts @@ -0,0 +1,13 @@ +import { OpenAPIObject } from '@nestjs/swagger'; + +const SWAGGER_CONFIG: Omit = { + openapi: '3.0.0', + info: { + title: 'RedisInsight Backend API', + description: 'RedisInsight Backend API', + version: '2.0.0', + }, + tags: [], +}; + +export default SWAGGER_CONFIG; diff --git a/redisinsight/api/config/test.ts b/redisinsight/api/config/test.ts new file mode 100644 index 0000000000..48f5df49c7 --- /dev/null +++ b/redisinsight/api/config/test.ts @@ -0,0 +1,18 @@ +import { join } from 'path'; + +const homedir = join(__dirname, '..'); + +module.exports = { + dir_path: { + homedir, + logs: join(homedir, 'logs'), + caCertificates: join(homedir, 'ca_certificates'), + clientCertificates: join(homedir, 'client_certificates'), + }, + server: { + env: 'test', + tls: !!process.env.SERVER_TLS || true, + tlsCert: process.env.SERVER_TLS_CERT, + tlsKey: process.env.SERVER_TLS_KEY, + }, +}; diff --git a/redisinsight/api/migration/1614164490968-initial-migration.ts b/redisinsight/api/migration/1614164490968-initial-migration.ts new file mode 100644 index 0000000000..5d518040e7 --- /dev/null +++ b/redisinsight/api/migration/1614164490968-initial-migration.ts @@ -0,0 +1,26 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class initialMigration1614164490968 implements MigrationInterface { + name = 'initialMigration1614164490968' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "client_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "certFilename" varchar NOT NULL, "keyFilename" varchar NOT NULL, CONSTRAINT "UQ_4966cf1c0e299df01049ebd53a5" UNIQUE ("name"))`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "type" varchar NOT NULL DEFAULT ('Redis Database'), "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar)`); + await queryRunner.query(`CREATE TABLE "ca_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "filename" varchar NOT NULL, CONSTRAINT "UQ_23be613e4fb204fd5a66916b0b3" UNIQUE ("name"))`); + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "type" varchar NOT NULL DEFAULT ('Redis Database'), "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "type" varchar NOT NULL DEFAULT ('Redis Database'), "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "ca_certificate"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`DROP TABLE "client_certificate"`); + } + +} diff --git a/redisinsight/api/migration/1615480887019-connection-type.ts b/redisinsight/api/migration/1615480887019-connection-type.ts new file mode 100644 index 0000000000..49c32b00ce --- /dev/null +++ b/redisinsight/api/migration/1615480887019-connection-type.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class connectionType1615480887019 implements MigrationInterface { + name = 'connectionType1615480887019' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "type" varchar NOT NULL DEFAULT ('Redis Database'), "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "type" varchar NOT NULL DEFAULT ('Redis Database'), "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/1615990079125-database-name-from-provider.ts b/redisinsight/api/migration/1615990079125-database-name-from-provider.ts new file mode 100644 index 0000000000..a3743645be --- /dev/null +++ b/redisinsight/api/migration/1615990079125-database-name-from-provider.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class databaseNameFromProvider1615990079125 implements MigrationInterface { + name = 'databaseNameFromProvider1615990079125' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "type" varchar NOT NULL DEFAULT ('Redis Database'), "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "type" varchar NOT NULL DEFAULT ('Redis Database'), "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "type", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/1615992183565-remove-database-type.ts b/redisinsight/api/migration/1615992183565-remove-database-type.ts new file mode 100644 index 0000000000..87a789206e --- /dev/null +++ b/redisinsight/api/migration/1615992183565-remove-database-type.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class removeDatabaseType1615992183565 implements MigrationInterface { + name = 'removeDatabaseType1615992183565' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "type" varchar NOT NULL DEFAULT ('Redis Database'), "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/1616520395940-oss-sentinel.ts b/redisinsight/api/migration/1616520395940-oss-sentinel.ts new file mode 100644 index 0000000000..d0ae1e19d3 --- /dev/null +++ b/redisinsight/api/migration/1616520395940-oss-sentinel.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class ossSentinel1616520395940 implements MigrationInterface { + name = 'ossSentinel1616520395940' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/1625771635418-agreements.ts b/redisinsight/api/migration/1625771635418-agreements.ts new file mode 100644 index 0000000000..ba9e24c495 --- /dev/null +++ b/redisinsight/api/migration/1625771635418-agreements.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class agreements1625771635418 implements MigrationInterface { + name = 'agreements1625771635418' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "agreements" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "version" varchar, "data" varchar)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "agreements"`); + } + +} diff --git a/redisinsight/api/migration/1626086601057-server-info.ts b/redisinsight/api/migration/1626086601057-server-info.ts new file mode 100644 index 0000000000..3191eb535c --- /dev/null +++ b/redisinsight/api/migration/1626086601057-server-info.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class serverInfo1626086601057 implements MigrationInterface { + name = 'serverInfo1626086601057' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "server" ("id" varchar PRIMARY KEY NOT NULL, "createDateTime" datetime NOT NULL DEFAULT (datetime('now')))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "server"`); + } + +} diff --git a/redisinsight/api/migration/1626904405170-database-hosting-provider.ts b/redisinsight/api/migration/1626904405170-database-hosting-provider.ts new file mode 100644 index 0000000000..6e83bd8c5c --- /dev/null +++ b/redisinsight/api/migration/1626904405170-database-hosting-provider.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class databaseHostingProvider1626904405170 implements MigrationInterface { + name = 'databaseHostingProvider1626904405170' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/1627556171227-settings.ts b/redisinsight/api/migration/1627556171227-settings.ts new file mode 100644 index 0000000000..0c0febf706 --- /dev/null +++ b/redisinsight/api/migration/1627556171227-settings.ts @@ -0,0 +1,14 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class settings1627556171227 implements MigrationInterface { + name = 'settings1627556171227' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" varchar)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "settings"`); + } + +} diff --git a/redisinsight/api/migration/1629729923740-database-modules.ts b/redisinsight/api/migration/1629729923740-database-modules.ts new file mode 100644 index 0000000000..9056cc08f4 --- /dev/null +++ b/redisinsight/api/migration/1629729923740-database-modules.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class databaseModules1629729923740 implements MigrationInterface { + name = 'databaseModules1629729923740' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/1634219846022-database-db-index.ts b/redisinsight/api/migration/1634219846022-database-db-index.ts new file mode 100644 index 0000000000..d9c8bccba2 --- /dev/null +++ b/redisinsight/api/migration/1634219846022-database-db-index.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class databaseDbIndex1634219846022 implements MigrationInterface { + name = 'databaseDbIndex1634219846022' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/1634557312500-encryption.ts b/redisinsight/api/migration/1634557312500-encryption.ts new file mode 100644 index 0000000000..a1add1be45 --- /dev/null +++ b/redisinsight/api/migration/1634557312500-encryption.ts @@ -0,0 +1,52 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class encryption1634557312500 implements MigrationInterface { + name = 'encryption1634557312500' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_client_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, CONSTRAINT "UQ_4966cf1c0e299df01049ebd53a5" UNIQUE ("name"))`); + await queryRunner.query(`INSERT INTO "temporary_client_certificate"("id", "name") SELECT "id", "name" FROM "client_certificate"`); + await queryRunner.query(`DROP TABLE "client_certificate"`); + await queryRunner.query(`ALTER TABLE "temporary_client_certificate" RENAME TO "client_certificate"`); + await queryRunner.query(`CREATE TABLE "temporary_ca_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, CONSTRAINT "UQ_23be613e4fb204fd5a66916b0b3" UNIQUE ("name"))`); + await queryRunner.query(`INSERT INTO "temporary_ca_certificate"("id", "name") SELECT "id", "name" FROM "ca_certificate"`); + await queryRunner.query(`DROP TABLE "ca_certificate"`); + await queryRunner.query(`ALTER TABLE "temporary_ca_certificate" RENAME TO "ca_certificate"`); + await queryRunner.query(`CREATE TABLE "temporary_client_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "encryption" varchar, "certificate" varchar, "key" varchar, CONSTRAINT "UQ_4966cf1c0e299df01049ebd53a5" UNIQUE ("name"))`); + await queryRunner.query(`INSERT INTO "temporary_client_certificate"("id", "name") SELECT "id", "name" FROM "client_certificate"`); + await queryRunner.query(`DROP TABLE "client_certificate"`); + await queryRunner.query(`ALTER TABLE "temporary_client_certificate" RENAME TO "client_certificate"`); + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, "encryption" varchar, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + await queryRunner.query(`CREATE TABLE "temporary_ca_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "encryption" varchar, "certificate" varchar, CONSTRAINT "UQ_23be613e4fb204fd5a66916b0b3" UNIQUE ("name"))`); + await queryRunner.query(`INSERT INTO "temporary_ca_certificate"("id", "name") SELECT "id", "name" FROM "ca_certificate"`); + await queryRunner.query(`DROP TABLE "ca_certificate"`); + await queryRunner.query(`ALTER TABLE "temporary_ca_certificate" RENAME TO "ca_certificate"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ca_certificate" RENAME TO "temporary_ca_certificate"`); + await queryRunner.query(`CREATE TABLE "ca_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, CONSTRAINT "UQ_23be613e4fb204fd5a66916b0b3" UNIQUE ("name"))`); + await queryRunner.query(`INSERT INTO "ca_certificate"("id", "name") SELECT "id", "name" FROM "temporary_ca_certificate"`); + await queryRunner.query(`DROP TABLE "temporary_ca_certificate"`); + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean NOT NULL, "verifyServerCert" boolean NOT NULL, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar, "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + await queryRunner.query(`ALTER TABLE "client_certificate" RENAME TO "temporary_client_certificate"`); + await queryRunner.query(`CREATE TABLE "client_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, CONSTRAINT "UQ_4966cf1c0e299df01049ebd53a5" UNIQUE ("name"))`); + await queryRunner.query(`INSERT INTO "client_certificate"("id", "name") SELECT "id", "name" FROM "temporary_client_certificate"`); + await queryRunner.query(`DROP TABLE "temporary_client_certificate"`); + await queryRunner.query(`ALTER TABLE "ca_certificate" RENAME TO "temporary_ca_certificate"`); + await queryRunner.query(`CREATE TABLE "ca_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "filename" varchar NOT NULL, CONSTRAINT "UQ_23be613e4fb204fd5a66916b0b3" UNIQUE ("name"))`); + await queryRunner.query(`INSERT INTO "ca_certificate"("id", "name") SELECT "id", "name" FROM "temporary_ca_certificate"`); + await queryRunner.query(`DROP TABLE "temporary_ca_certificate"`); + await queryRunner.query(`ALTER TABLE "client_certificate" RENAME TO "temporary_client_certificate"`); + await queryRunner.query(`CREATE TABLE "client_certificate" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "certFilename" varchar NOT NULL, "keyFilename" varchar NOT NULL, CONSTRAINT "UQ_4966cf1c0e299df01049ebd53a5" UNIQUE ("name"))`); + await queryRunner.query(`INSERT INTO "client_certificate"("id", "name") SELECT "id", "name" FROM "temporary_client_certificate"`); + await queryRunner.query(`DROP TABLE "temporary_client_certificate"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts new file mode 100644 index 0000000000..219c9539b2 --- /dev/null +++ b/redisinsight/api/migration/index.ts @@ -0,0 +1,27 @@ +import { initialMigration1614164490968 } from './1614164490968-initial-migration'; +import { connectionType1615480887019 } from './1615480887019-connection-type'; +import { databaseNameFromProvider1615990079125 } from './1615990079125-database-name-from-provider'; +import { removeDatabaseType1615992183565 } from './1615992183565-remove-database-type'; +import { ossSentinel1616520395940 } from './1616520395940-oss-sentinel'; +import { agreements1625771635418 } from './1625771635418-agreements'; +import { serverInfo1626086601057 } from './1626086601057-server-info'; +import { databaseHostingProvider1626904405170 } from './1626904405170-database-hosting-provider'; +import { settings1627556171227 } from './1627556171227-settings'; +import { databaseModules1629729923740 } from './1629729923740-database-modules'; +import { databaseDbIndex1634219846022 } from './1634219846022-database-db-index'; +import { encryption1634557312500 } from './1634557312500-encryption'; + +export default [ + initialMigration1614164490968, + connectionType1615480887019, + databaseNameFromProvider1615990079125, + removeDatabaseType1615992183565, + ossSentinel1616520395940, + agreements1625771635418, + serverInfo1626086601057, + databaseHostingProvider1626904405170, + settings1627556171227, + databaseModules1629729923740, + databaseDbIndex1634219846022, + encryption1634557312500, +]; diff --git a/redisinsight/api/nest-cli.json b/redisinsight/api/nest-cli.json new file mode 100644 index 0000000000..316a787c6a --- /dev/null +++ b/redisinsight/api/nest-cli.json @@ -0,0 +1,9 @@ +{ + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "assets": [ + "static/**/*" + ] + } +} diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json new file mode 100644 index 0000000000..4be212dac8 --- /dev/null +++ b/redisinsight/api/package.json @@ -0,0 +1,132 @@ +{ + "name": "redisinsight-api", + "version": "2.0.0", + "description": "RedisInsight API", + "private": true, + "author": { + "name": "Redis Ltd.", + "email": "support@redis.com", + "url": "https://redis.com/redis-enterprise/redis-insight" + }, + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "build:prod": "rimraf dist && nest build -p ./tsconfig.build.prod.json && cross-env NODE_ENV=production", + "build:stage": "rimraf dist && nest build && cross-env NODE_ENV=staging", + "format": "prettier --write \"src/**/*.ts\"", + "lint": "eslint --ext .ts .", + "start": "nest start", + "start:dev": "cross-env NODE_ENV=development SERVER_STATIC_CONTENT=1 nest start --watch", + "start:debug": "nest start --debug --watch", + "start:stage": "cross-env NODE_ENV=staging SERVER_STATIC_CONTENT=true node dist/src/main", + "start:prod": "cross-env NODE_ENV=production node dist/src/main", + "test": "./node_modules/.bin/jest -w 1", + "test:watch": "jest --watch -w 1", + "test:cov": "./node_modules/.bin/jest --coverage -w 1", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand -w 1", + "test:e2e": "jest --config ./test/jest-e2e.json -w 1", + "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config ./config/ormconfig.ts", + "test:api": "ts-mocha --paths -p test/api/api.tsconfig.json -require test/api/api.deps.init.ts test/**/*.test.ts --exit --timeout=60000", + "test:api:cov": "nyc --reporter=html --reporter=text --reporter=text-summary yarn run test:api", + "test:api:ci:cov": "nyc -r text -r text-summary yarn run test:api --reporter mocha-multi-reporters --reporter-options configFile=test/api/reporters.json && nyc merge .nyc_output ./coverage/test-run-coverage.json", + "typeorm:migrate": "cross-env NODE_ENV=production yarn typeorm migration:generate -- -n migration", + "typeorm:run": "yarn typeorm migration:run" + }, + "dependencies": { + "@nestjs/common": "^7.6.15", + "@nestjs/core": "^7.0.0", + "@nestjs/event-emitter": "^1.0.0", + "@nestjs/platform-express": "^7.0.0", + "@nestjs/serve-static": "^2.1.3", + "@nestjs/swagger": "^4.6.1", + "@nestjs/typeorm": "^7.1.5", + "analytics-node": "^4.0.1", + "axios": "^0.21.0", + "body-parser": "^1.19.0", + "class-transformer": "^0.2.3", + "class-validator": "^0.12.2", + "express": "^4.17.1", + "ioredis": "^4.27.1", + "is-glob": "^4.0.1", + "jsonpath": "^1.1.1", + "keytar": "^7.7.0", + "lodash": "^4.17.20", + "nest-router": "^1.0.9", + "nest-winston": "^1.4.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^6.6.7", + "source-map-support": "^0.5.19", + "sqlite3": "^5.0.2", + "swagger-ui-express": "^4.1.4", + "typeorm": "^0.2.29", + "uuid": "^8.3.2", + "winston": "^3.3.3", + "winston-daily-rotate-file": "^4.5.0" + }, + "devDependencies": { + "@mochajs/json-file-reporter": "^1.3.0", + "@nestjs/cli": "^7.5.4", + "@nestjs/schematics": "^7.0.0", + "@nestjs/testing": "^7.0.0", + "@types/axios": "^0.14.0", + "@types/express": "^4.17.3", + "@types/ioredis": "^4.22.3", + "@types/jest": "^26.0.15", + "@types/lodash": "^4.14.167", + "@types/node": "14.14.10", + "@types/supertest": "^2.0.8", + "@typescript-eslint/eslint-plugin": "^4.8.1", + "@typescript-eslint/parser": "^4.8.1", + "chai": "^4.3.4", + "concurrently": "^5.3.0", + "cross-env": "^7.0.3", + "eslint": "^7.1.0", + "eslint-config-airbnb-typescript": "^12.3.1", + "eslint-config-prettier": "^6.10.0", + "eslint-plugin-import": "^2.20.1", + "eslint-plugin-sonarjs": "^0.9.1", + "ioredis-mock": "^5.5.4", + "jest": "^26.6.3", + "jest-when": "^3.2.1", + "joi": "^17.4.0", + "mocha": "^8.4.0", + "mocha-junit-reporter": "^2.0.0", + "mocha-multi-reporters": "^1.5.1", + "node-version-compare": "^1.0.3", + "nyc": "^15.1.0", + "object-diff": "^0.0.4", + "rimraf": "^3.0.2", + "supertest": "^4.0.2", + "ts-jest": "^26.1.0", + "ts-loader": "^6.2.1", + "ts-mocha": "^8.0.0", + "ts-node": "^9.1.1", + "tsconfig-paths": "^3.9.0", + "tsconfig-paths-webpack-plugin": "^3.3.0", + "typescript": "^4.0.5" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "coverageDirectory": "../coverage", + "coveragePathIgnorePatterns": [ + "/node_modules/", + ".entity.ts$", + ".spec.ts$" + ], + "testEnvironment": "node", + "moduleNameMapper": { + "src/(.*)": "/$1", + "apiSrc/(.*)": "/$1", + "tests/(.*)": "/__tests__/$1" + } + } +} diff --git a/redisinsight/api/package.tmp.json b/redisinsight/api/package.tmp.json new file mode 100644 index 0000000000..afda4b38ad --- /dev/null +++ b/redisinsight/api/package.tmp.json @@ -0,0 +1,65 @@ +{ + "name": "redisinsight-api", + "version": "2.1.0", + "description": "RedisInsight API", + "author": "Artyom Podymov ,", + "private": true, + "license": "UNLICENSED", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "build:prod": "rimraf dist && nest build && cross-env NODE_ENV=production", + "build:stage": "rimraf dist && nest build && cross-env NODE_ENV=staging", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "cross-env NODE_ENV=development nest start --watch", + "start:debug": "nest start --debug --watch", + "start:stage": "rimraf dist && nest build && cross-env NODE_ENV=staging node dist/src/main", + "start:prod": "rimraf dist && nest build && cross-env NODE_ENV=production node dist/src/main", + "test": "../../node_modules/.bin/jest -w 1", + "test:watch": "jest --watch -w 1", + "test:cov": "../../node_modules/.bin/jest --coverage -w 1", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand -w 1", + "test:e2e": "jest --config ./test/jest-e2e.json -w 1", + "typeorm": "ts-node -r tsconfig-paths/register ../../node_modules/typeorm/cli.js --config ./config/ormconfig.ts", + "typeorm:migrate": "cross-env NODE_ENV=production yarn typeorm migration:generate -- -n migration", + "typeorm:run": "yarn typeorm migration:run" + }, + "dependencies": { + "sql.js": "^1.4.0" + }, + "devDependencies": { + "@nestjs/cli": "^7.5.4", + "cross-env": "^7.0.3", + "jest": "^26.6.3", + "jest-when": "^3.2.1", + "rimraf": "^3.0.2" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "coverageDirectory": "../coverage", + "coveragePathIgnorePatterns": [ + "/node_modules/", + ".entity.ts$", + ".spec.ts$" + ], + "testEnvironment": "node", + "moduleNameMapper": { + "src/(.*)": "/$1", + "apiSrc/(.*)": "/$1", + "tests/(.*)": "/__tests__/$1" + }, + "setupFilesAfterEnv": [ + "../test/jest.setup.ts" + ] + } +} diff --git a/redisinsight/api/src/__mocks__/analytics.ts b/redisinsight/api/src/__mocks__/analytics.ts new file mode 100644 index 0000000000..659e31e891 --- /dev/null +++ b/redisinsight/api/src/__mocks__/analytics.ts @@ -0,0 +1,39 @@ +export const mockInstancesAnalyticsService = () => ({ + sendInstanceAddedEvent: jest.fn(), + sendInstanceAddFailedEvent: jest.fn(), + sendInstanceEditedEvent: jest.fn(), + sendInstanceDeletedEvent: jest.fn(), + sendConnectionFailedEvent: jest.fn(), +}); + +export const mockBrowserAnalyticsService = () => ({ + sendKeysScannedEvent: jest.fn(), + sendKeyAddedEvent: jest.fn(), + sendKeyTTLChangedEvent: jest.fn(), + sendKeysDeletedEvent: jest.fn(), + sendKeyValueAddedEvent: jest.fn(), + sendKeyValueEditedEvent: jest.fn(), + sendKeyValueRemovedEvent: jest.fn(), + sendKeyScannedEvent: jest.fn(), + sendGetListElementByIndexEvent: jest.fn(), + sendJsonPropertyAddedEvent: jest.fn(), + sendJsonPropertyEditedEvent: jest.fn(), + sendJsonPropertyDeletedEvent: jest.fn(), + sendJsonArrayPropertyAppendEvent: jest.fn(), +}); + +export const mockCliAnalyticsService = () => ({ + sendCliClientCreatedEvent: jest.fn(), + sendCliClientCreationFailedEvent: jest.fn(), + sendCliClientDeletedEvent: jest.fn(), + sendCliClientRecreatedEvent: jest.fn(), + sendCliCommandExecutedEvent: jest.fn(), + sendCliCommandErrorEvent: jest.fn(), + sendCliClusterCommandExecutedEvent: jest.fn(), + sendCliConnectionErrorEvent: jest.fn(), +}); + +export const mockSettingsAnalyticsService = () => ({ + sendAnalyticsAgreementChange: jest.fn(), + sendSettingsUpdatedEvent: jest.fn(), +}); diff --git a/redisinsight/api/src/__mocks__/app-settings.ts b/redisinsight/api/src/__mocks__/app-settings.ts new file mode 100644 index 0000000000..4f0106d63f --- /dev/null +++ b/redisinsight/api/src/__mocks__/app-settings.ts @@ -0,0 +1,42 @@ +import { IAgreement } from 'src/models'; +import { + AgreementsEntity, + IAgreementsJSON, +} from 'src/modules/core/models/agreements.entity'; +import { + ISettingsJSON, + SettingsEntity, +} from 'src/modules/core/models/settings.entity'; + +export const mockAppAgreement: IAgreement = { + defaultValue: false, + required: true, + since: '1.0.0', + disabled: false, + displayInSetting: false, + editable: false, + title: 'License Terms', + label: 'I have read and understood the License Terms', +}; + +export const mockAgreementsJSON = { + version: null, +}; + +export const mockAgreementsEntity: AgreementsEntity = { + id: 1, + version: null, + data: null, + toJSON: (): IAgreementsJSON => mockAgreementsJSON, +}; + +export const mockSettingsJSON: ISettingsJSON = { + theme: null, + scanThreshold: null, +}; + +export const mockSettingsEntity: SettingsEntity = { + id: 1, + data: null, + toJSON: (): ISettingsJSON => mockSettingsJSON, +}; diff --git a/redisinsight/api/src/__mocks__/autodiscovery-tools.ts b/redisinsight/api/src/__mocks__/autodiscovery-tools.ts new file mode 100644 index 0000000000..f59823ffbb --- /dev/null +++ b/redisinsight/api/src/__mocks__/autodiscovery-tools.ts @@ -0,0 +1,61 @@ +import { RedisEnterpriseDatabase } from 'src/modules/redis-enterprise/dto/cluster.dto'; +import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; +import { GetRedisCloudSubscriptionResponse, RedisCloudDatabase } from 'src/modules/redis-enterprise/dto/cloud.dto'; +import { RedisCloudSubscriptionStatus } from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; +import { SentinelMaster, SentinelMasterStatus } from 'src/modules/redis-sentinel/models/sentinel'; + +export const mockAutodiscoveryAnalyticsService = () => ({ + sendGetREClusterDbsSucceedEvent: jest.fn(), + sendGetREClusterDbsFailedEvent: jest.fn(), + sendGetRECloudSubsSucceedEvent: jest.fn(), + sendGetRECloudSubsFailedEvent: jest.fn(), + sendGetRECloudDbsSucceedEvent: jest.fn(), + sendGetRECloudDbsFailedEvent: jest.fn(), + sendGetSentinelMastersSucceedEvent: jest.fn(), + sendGetSentinelMastersFailedEvent: jest.fn(), +}); + +export const mockRedisEnterpriseDatabaseDto: RedisEnterpriseDatabase = { + uid: 1, + address: '172.17.0.2', + dnsName: 'redis-12000.clus.local', + modules: [], + name: 'db', + options: {}, + port: 12000, + status: RedisEnterpriseDatabaseStatus.Active, + tls: false, + password: null, +}; + +export const mockRedisCloudSubscriptionDto: GetRedisCloudSubscriptionResponse = { + id: 1, + name: 'Basic subscription example', + numberOfDatabases: 1, + provider: 'AWS', + region: 'us-east-1', + status: RedisCloudSubscriptionStatus.Active, +}; + +export const mockRedisCloudDatabaseDto: RedisCloudDatabase = { + databaseId: 51166493, + subscriptionId: 1, + modules: [], + name: 'Database', + options: {}, + publicEndpoint: 'redis.us-east-1-1.rlrcp.com:12315', + sslClientAuthentication: false, + status: RedisEnterpriseDatabaseStatus.Active, +}; + +export const mockSentinelMasterDto: SentinelMaster = { + name: 'mymaster', + host: '127.0.0.1', + port: 6379, + numberOfSlaves: 1, + status: SentinelMasterStatus.Active, + endpoints: [{ + host: '127.0.0.1', + port: 26379, + }], +}; diff --git a/redisinsight/api/src/__mocks__/certificates.ts b/redisinsight/api/src/__mocks__/certificates.ts new file mode 100644 index 0000000000..eee13e69a5 --- /dev/null +++ b/redisinsight/api/src/__mocks__/certificates.ts @@ -0,0 +1,44 @@ +import { + CaCertDto, + ClientCertPairDto, +} from 'src/modules/instances/dto/database-instance.dto'; +import { CaCertificateEntity } from 'src/modules/core/models/ca-certificate.entity'; +import { ClientCertificateEntity } from 'src/modules/core/models/client-certificate.entity'; + +export const mockCaCertDto: CaCertDto = { + name: 'ca-cert', + cert: '-----BEGIN CERTIFICATE-----\nMIIDejCCAmKgAwIBAgIUehUr5AHdJM', +}; + +export const mockClientCertDto: ClientCertPairDto = { + name: 'client-cert', + cert: '-----BEGIN CERTIFICATE-----\nMIIDejCCAmKgAwIBAgIUehUr5AHdJM', + key: '-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAAM', +}; + +export const mockCaCertEntity: CaCertificateEntity = { + id: 'a77b23c1-7816-4ea4-b61f-d37795a0f805', + name: mockCaCertDto.name, + encryption: null, + certificate: mockCaCertDto.cert, + databases: [], +}; + +export const mockClientCertEntity: ClientCertificateEntity = { + id: 'a77b23c1-7816-4ea4-b61f-d37795a0f809', + name: mockClientCertDto.name, + encryption: null, + certificate: mockClientCertDto.cert, + key: mockClientCertDto.key, + databases: [], +}; + +export const mockCaCertificatesService = () => ({ + getAll: jest.fn(), + getOneById: jest.fn(), +}); + +export const mockClientCertificatesService = () => ({ + getAll: jest.fn(), + getOneById: jest.fn(), +}); diff --git a/redisinsight/api/src/__mocks__/commands.ts b/redisinsight/api/src/__mocks__/commands.ts new file mode 100644 index 0000000000..bf3004064a --- /dev/null +++ b/redisinsight/api/src/__mocks__/commands.ts @@ -0,0 +1,169 @@ +export const mockMainCommands = { + 'ACL LOAD': { + summary: 'Reload the ACLs from the configured ACL file', + complexity: 'O(N). Where N is the number of configured users.', + since: '6.0.0', + group: 'server', + }, +}; + +export const mockRedisearchCommands = { + 'FT.CREATE': { + summary: 'Creates an index with the given spec', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'key', + }, + ], + since: '1.0.0', + group: 'search', + }, +}; + +export const mockRedijsonCommands = { + 'JSON.DEL': { + summary: 'Deletes a value', + complexity: 'O(N), where N is the size of the deleted value', + arguments: [ + { + name: 'key', + type: 'key', + }, + { + name: 'path', + type: 'json path string', + optional: true, + }, + ], + since: '1.0.0', + group: 'json', + }, +}; + +export const mockRedistimeseriesCommands = { + 'TS.CREATE': { + summary: 'Create a new time-series', + complexity: 'O(1)', + arguments: [ + { + name: 'key', + type: 'key', + }, + { + type: 'integer', + command: 'RETENTION', + name: 'retentionTime', + optional: true, + }, + { + type: 'enum', + command: 'ENCODING', + enum: [ + 'UNCOMPRESSED', + 'COMPRESSED', + ], + optional: true, + }, + { + type: 'integer', + command: 'CHUNK_SIZE', + name: 'size', + optional: true, + }, + { + type: 'enum', + command: 'DUPLICATE_POLICY', + name: 'policy', + enum: [ + 'BLOCK', + 'FIRST', + 'LAST', + 'MIN', + 'MAX', + 'SUM', + ], + optional: true, + }, + { + command: 'LABELS', + name: [ + 'label', + 'value', + ], + type: [ + 'string', + 'string', + ], + multiple: true, + optional: true, + }, + ], + since: '1.0.0', + group: 'timeseries', + }, +}; + +export const mockRedisaiCommands = { + 'AI.TENSORSET': { + summary: 'stores a tensor as the value of a key.', + complexity: 'O(1)', + arguments: [ + { + name: 'key', + type: 'key', + }, + { + name: 'type', + type: 'enum', + enum: [ + 'FLOAT', 'DOUBLE', 'INT8', 'INT16', 'INT32', 'INT64', 'UINT8', 'UINT16', 'STRING', 'BOOL', + ], + }, + { + name: 'shape', + type: 'integer', + multiple: true, + }, + { + name: 'blob', + command: 'BLOB', + type: 'string', + optional: true, + }, + { + name: 'value', + command: 'VALUES', + type: 'string', + multiple: true, + optional: true, + }, + + ], + since: '1.2.5', + group: 'tensor', + }, +}; + +export const mockRedisgraphCommands = { + 'GRAPH.QUERY': { + summary: 'Queries the graph', + arguments: [ + { + name: 'graph', + type: 'key', + }, + { + name: 'query', + type: 'string', + }, + ], + since: '1.0.0', + group: 'graph', + }, +}; + +export const mockCommandsJsonProvider = () => ({ + getCommands: jest.fn(), +}); diff --git a/redisinsight/api/src/__mocks__/common.ts b/redisinsight/api/src/__mocks__/common.ts new file mode 100644 index 0000000000..a94617317c --- /dev/null +++ b/redisinsight/api/src/__mocks__/common.ts @@ -0,0 +1,47 @@ +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; + +export type MockType = { + [P in keyof T]: jest.Mock; +}; + +export const mockRedisConsumer = () => ({ + execCommand: jest.fn(), + execPipeline: jest.fn(), + execMulti: jest.fn(), +}); + +export const mockRedisClusterConsumer = () => ({ + execCommand: jest.fn(), + execCommandFromNodes: jest.fn(), + execCommandFromNode: jest.fn(), + execPipeline: jest.fn(), + getNodes: jest.fn(), +}); + +export const mockQueryBuilderGetOne = jest.fn(); +export const mockQueryBuilderGetMany = jest.fn(); +export const mockCreateQueryBuilder = jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + getMany: mockQueryBuilderGetMany, + getOne: mockQueryBuilderGetOne, +})); + +export const mockRepository = jest.fn(() => ({ + findOne: jest.fn(), + find: jest.fn(), + findByIds: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + remove: jest.fn(), + createQueryBuilder: mockCreateQueryBuilder, +})); + +export const mockSettingsProvider = (): ISettingsProvider => ({ + getSettings: jest.fn(), + updateSettings: jest.fn(), + getAgreementsSpec: jest.fn(), +}); diff --git a/redisinsight/api/src/__mocks__/encryption.ts b/redisinsight/api/src/__mocks__/encryption.ts new file mode 100644 index 0000000000..96169724a0 --- /dev/null +++ b/redisinsight/api/src/__mocks__/encryption.ts @@ -0,0 +1,23 @@ +export const mockDataToEncrypt = 'stringtoencrypt'; +export const mockKeytarPassword = 'somepassword'; +export const mockEncryptResult = { + data: '4a558dfef5c1abbdf745232614194ee9', + encryption: 'KEYTAR', +}; + +export const mockEncryptionService = () => ({ + getAvailableEncryptionStrategies: jest.fn(), + encrypt: jest.fn(), + decrypt: jest.fn(), +}); + +export const mockEncryptionStrategy = () => ({ + isAvailable: jest.fn(), + encrypt: jest.fn(), + decrypt: jest.fn(), +}); + +export const mockKeytarModule = { + getPassword: jest.fn(), + setPassword: jest.fn(), +}; diff --git a/redisinsight/api/src/__mocks__/errors.ts b/redisinsight/api/src/__mocks__/errors.ts new file mode 100644 index 0000000000..6ec6069dff --- /dev/null +++ b/redisinsight/api/src/__mocks__/errors.ts @@ -0,0 +1,31 @@ +import { ReplyError } from 'src/models'; + +export const mockRedisNoPermError: ReplyError = { + name: 'ReplyError', + command: 'GET', + message: 'NOPERM this user has no permissions.', +}; + +export const mockRedisWrongNumberOfArgumentsError: ReplyError = { + name: 'ReplyError', + command: 'GET', + message: 'ERR wrong number of arguments.', +}; + +export const mockRedisWrongTypeError: ReplyError = { + name: 'ReplyError', + command: 'GET', + message: 'WRONGTYPE Operation against a key holding the wrong kind of value.', +}; + +export const mockRedisMovedError: ReplyError = { + name: 'ReplyError', + command: 'GET', + message: 'MOVED 7008 127.0.0.1:7002', +}; + +export const mockRedisAskError: ReplyError = { + name: 'ReplyError', + command: 'GET', + message: 'ASK 7008 127.0.0.1:7002', +}; diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts new file mode 100644 index 0000000000..b479746981 --- /dev/null +++ b/redisinsight/api/src/__mocks__/index.ts @@ -0,0 +1,10 @@ +export * from './certificates'; +export * from './commands'; +export * from './common'; +export * from './encryption'; +export * from './errors'; +export * from './redis-databases'; +export * from './redis-info'; +export * from './app-settings'; +export * from './autodiscovery-tools'; +export * from './analytics'; diff --git a/redisinsight/api/src/__mocks__/redis-databases.ts b/redisinsight/api/src/__mocks__/redis-databases.ts new file mode 100644 index 0000000000..e29d85c709 --- /dev/null +++ b/redisinsight/api/src/__mocks__/redis-databases.ts @@ -0,0 +1,87 @@ +import { + ConnectionType, + DatabaseInstanceEntity, + HostingProvider, +} from 'src/modules/core/models/database-instance.entity'; +import { mockCaCertEntity, mockClientCertEntity } from './certificates'; + +export const mockStandaloneDatabaseEntity: DatabaseInstanceEntity = { + id: 'a77b23c1-7816-4ea4-b61f-d37795a0f805', + host: 'localhost', + port: 6379, + db: 0, + name: 'redis-database', + nameFromProvider: null, + username: null, + password: null, + tls: true, + verifyServerCert: true, + caCert: mockCaCertEntity, + clientCert: mockClientCertEntity, + lastConnection: null, + connectionType: ConnectionType.STANDALONE, + sentinelMasterName: null, + sentinelMasterUsername: null, + sentinelMasterPassword: null, + nodes: null, + provider: HostingProvider.LOCALHOST, + modules: '[]', + encryption: null, +}; + +export const mockOSSClusterDatabaseEntity: DatabaseInstanceEntity = { + id: '3a41f8ea-a36a-11eb-bcbc-0242ac130002', + host: 'localhost', + port: 7001, + db: null, + name: 'oss-cluster', + nameFromProvider: null, + username: null, + password: null, + tls: true, + verifyServerCert: true, + caCert: mockCaCertEntity, + clientCert: mockClientCertEntity, + lastConnection: null, + connectionType: ConnectionType.CLUSTER, + sentinelMasterName: null, + sentinelMasterUsername: null, + sentinelMasterPassword: null, + nodes: '[{"host":"localhost","port":7001},{"host":"localhost","port":7002}]', + provider: HostingProvider.LOCALHOST, + modules: '[]', + encryption: null, +}; + +export const mockSentinelDatabaseEntity: DatabaseInstanceEntity = { + id: 'a77b23c1-7816-4ea4-b61f-d37795a0f805', + host: 'localhost', + port: 26379, + db: 0, + name: 'sentinel-database', + nameFromProvider: null, + username: null, + password: null, + tls: true, + verifyServerCert: true, + caCert: mockCaCertEntity, + clientCert: mockClientCertEntity, + lastConnection: null, + connectionType: ConnectionType.SENTINEL, + sentinelMasterName: 'master-group', + sentinelMasterUsername: null, + sentinelMasterPassword: null, + nodes: '[{"host":"localhost","port":5001}]', + provider: HostingProvider.LOCALHOST, + modules: '[]', + encryption: null, +}; + +export const mockDatabasesProvider = () => ({ + exists: jest.fn(), + getAll: jest.fn(), + getOneById: jest.fn(), + update: jest.fn(), + patch: jest.fn(), + save: jest.fn(), +}); diff --git a/redisinsight/api/src/__mocks__/redis-info.ts b/redisinsight/api/src/__mocks__/redis-info.ts new file mode 100644 index 0000000000..4fc31e8cc1 --- /dev/null +++ b/redisinsight/api/src/__mocks__/redis-info.ts @@ -0,0 +1,132 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +export const mockRedisServerInfoResponse: string = ' # Server\r\n' + + 'redis_version:6.0.5\r\n' + + 'redis_mode:standalone\r\n' + + 'os:Linux 4.15.0-1087-gcp x86_64\r\n' + + 'uptime_in_seconds:1000\r\n' + + 'arch_bits:64\r\n' + + 'tcp_port:11113\r\n'; + +export const mockRedisClientsInfoResponse: string = '# Clients\r\n' + + 'connected_clients:1\r\n' + + 'client_longest_output_list:0\r\n' + + 'client_biggest_input_buf:0\r\n' + + 'blocked_clients:0\r\n'; + +export const mockRedisKeyspaceInfoResponse: string = '# Keyspace\r\ndb0:keys=1,expires=0,avg_ttl=0\r\n'; + +export const mockRedisMemoryInfoResponse: string = '# Memory\r\n' + + 'used_memory:1000000\r\n' + + 'used_memory_human:1M\r\n' + + 'used_memory_rss:1000000\r\n' + + 'used_memory_peak:1000000\r\n' + + 'used_memory_peak_human:1M\r\n' + + 'used_memory_lua:37888\r\n' + + 'mem_fragmentation_ratio:1\r\n' + + 'mem_allocator:jemalloc-5.1.0\r\n'; + +export const mockRedisReplicationInfoResponse: string = '# Replication\r\n' + + 'role:master\r\n' + + 'connected_slaves:0\r\n' + + 'master_repl_offset:0\r\n' + + 'repl_backlog_active:0\r\n' + + 'repl_backlog_size:1000\r\n' + + 'repl_backlog_first_byte_offset:0\r\n' + + 'repl_backlog_histlen:0\r\n'; + +export const mockRedisStatsInfoResponse: string = '# Stats\r\nkeyspace_hits:1000\r\nkeyspace_misses:0\r\n'; + +export const mockRedisClusterOkInfoResponse: string = ' # Cluster\r\n' + + 'cluster_state:ok\r\n' + + 'cluster_slots_assigned:16384\r\n' + + 'cluster_slots_ok:16384\r\n' + + 'cluster_slots_pfail:0\r\n' + + 'cluster_slots_fail:0\r\n' + + 'cluster_known_nodes:6\r\n' + + 'cluster_size:3\r\n' + + 'cluster_current_epoch:6\r\n' + + 'cluster_my_epoch:2\r\n' + + 'cluster_current_epoch:6\r\n' + + 'cluster_slots_fail:0\r\n'; + +export const mockRedisClusterFailInfoResponse: string = ' # Cluster\r\n' + + 'cluster_state:fail\r\n' + + 'cluster_slots_assigned:16384\r\n' + + 'cluster_slots_ok:16384\r\n' + + 'cluster_slots_pfail:0\r\n' + + 'cluster_slots_fail:0\r\n' + + 'cluster_known_nodes:6\r\n' + + 'cluster_size:3\r\n' + + 'cluster_current_epoch:6\r\n' + + 'cluster_my_epoch:2\r\n' + + 'cluster_current_epoch:6\r\n' + + 'cluster_slots_fail:0\r\n'; + +export const mockRedisClusterDisabledInfoResponse: string = '# Cluster\r\ncluster_enabled:0\r\n'; + +export const mockSentinelMasterInOkState: string[] = [ + 'name', 'mymaster', 'ip', '127.0.0.1', 'port', '6379', 'num-slaves', '1', 'flags', 'master', +]; +export const mockSentinelMasterInDownState: string[] = [ + 'name', 'mymaster', 'ip', '127.0.0.1', 'port', '6379', 'num-slaves', '1', 'flags', 's_down,masrer', +]; + +export const mockRedisSentinelMasterResponse: Array = [ + mockSentinelMasterInOkState, +]; + +// eslint-disable-next-line max-len +export const mockRedisClusterNodesResponse: string = '07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:30004@31004 slave e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 0 1426238317239 4 connected\n' + + 'e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 127.0.0.1:30001@31001 myself,master - 0 0 1 connected 0-16383'; + +export const mockStandaloneRedisInfoReply: string = `${mockRedisServerInfoResponse +}\r\n${ + mockRedisClientsInfoResponse +}\r\n${ + mockRedisMemoryInfoResponse +}\r\n${ + mockRedisStatsInfoResponse +}\r\n${ + mockRedisReplicationInfoResponse +}\r\n${ + mockRedisClusterDisabledInfoResponse +}\r\n${ + mockRedisKeyspaceInfoResponse}`; + +export const mockWhitelistCommandsResponse = [ + 'get', + 'custom.command', +]; + +export const mockRedisCommandReply: any[][] = [ + [ + 'get', + 0, + ['readonly'], + ], + [ + 'role', + 0, + ['readonly'], + ], + [ + 'set', + 0, + ['write'], + ], + [ + 'xread', + 0, + ['readonly'], + ], + [ + 'custom.command', + 0, + ['readonly'], + ], +]; + +export const mockPluginWhiteListCommandsResponse: string[] = [ + 'get', + 'custom.command', +]; diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts new file mode 100644 index 0000000000..d3cad4134e --- /dev/null +++ b/redisinsight/api/src/app.module.ts @@ -0,0 +1,80 @@ +import * as fs from 'fs'; +import { Module, OnModuleInit } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { RouterModule } from 'nest-router'; +import { join } from 'path'; +import config from 'src/utils/config'; +import { PluginModule } from 'src/modules/plugin/plugin.module'; +import { CommandsModule } from 'src/modules/commands/commands.module'; +import { SharedModule } from './modules/shared/shared.module'; +import { InstancesModule } from './modules/instances/instances.module'; +import { BrowserModule } from './modules/browser/browser.module'; +import { RedisEnterpriseModule } from './modules/redis-enterprise/redis-enterprise.module'; +import { RedisSentinelModule } from './modules/redis-sentinel/redis-sentinel.module'; +import { CliModule } from './modules/cli/cli.module'; +import { SettingsController } from './controllers/settings.controller'; +import { ServerInfoController } from './controllers/server-info.controller'; +import { routes } from './app.routes'; +import ormConfig from '../config/ormconfig'; + +const SERVER_CONFIG = config.get('server'); +const PATH_CONFIG = config.get('dir_path'); + +@Module({ + imports: [ + TypeOrmModule.forRoot(ormConfig), + RouterModule.forRoutes(routes), + SharedModule, + InstancesModule, + RedisEnterpriseModule, + RedisSentinelModule, + BrowserModule, + CliModule, + PluginModule, + CommandsModule, + EventEmitterModule.forRoot(), + ...(SERVER_CONFIG.staticContent + ? [ + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', '..', '..', 'ui', 'dist'), + exclude: ['/api/**', `${SERVER_CONFIG.customPluginsUri}/**`, `${SERVER_CONFIG.staticUri}/**`], + }), + ] + : []), + ServeStaticModule.forRoot({ + serveRoot: SERVER_CONFIG.customPluginsUri, + rootPath: join(PATH_CONFIG.customPlugins), + exclude: ['/api/**'], + serveStaticOptions: { + fallthrough: false, + }, + }), + ServeStaticModule.forRoot({ + serveRoot: SERVER_CONFIG.staticUri, + rootPath: join(PATH_CONFIG.staticDir), + exclude: ['/api/**'], + serveStaticOptions: { + fallthrough: false, + }, + }), + ], + controllers: [SettingsController, ServerInfoController], + providers: [], +}) +export class AppModule implements OnModuleInit { + onModuleInit() { + // creating required folders + const foldersToCreate = [ + PATH_CONFIG.pluginsAssets, + PATH_CONFIG.customPlugins, + ]; + + foldersToCreate.forEach((folder) => { + if (!fs.existsSync(folder)) { + fs.mkdirSync(folder, { recursive: true }); + } + }); + } +} diff --git a/redisinsight/api/src/app.routes.ts b/redisinsight/api/src/app.routes.ts new file mode 100644 index 0000000000..0fa43c4cee --- /dev/null +++ b/redisinsight/api/src/app.routes.ts @@ -0,0 +1,31 @@ +import { Routes } from 'nest-router'; +import { InstancesModule } from 'src/modules/instances/instances.module'; +import { BrowserModule } from 'src/modules/browser/browser.module'; +import { RedisEnterpriseModule } from 'src/modules/redis-enterprise/redis-enterprise.module'; +import { RedisSentinelModule } from 'src/modules/redis-sentinel/redis-sentinel.module'; +import { CliModule } from 'src/modules/cli/cli.module'; + +export const routes: Routes = [ + { + path: '/instance', + module: InstancesModule, + children: [ + { + path: '/:dbInstance', + module: BrowserModule, + }, + { + path: '/:dbInstance', + module: CliModule, + }, + ], + }, + { + path: '/redis-enterprise', + module: RedisEnterpriseModule, + }, + { + path: '/sentinel', + module: RedisSentinelModule, + }, +]; diff --git a/redisinsight/api/src/constants/agreements-spec.json b/redisinsight/api/src/constants/agreements-spec.json new file mode 100644 index 0000000000..6555d5a8fe --- /dev/null +++ b/redisinsight/api/src/constants/agreements-spec.json @@ -0,0 +1,56 @@ +{ + "version": "1.0.4", + "agreements": { + "analytics": { + "defaultValue": false, + "displayInSetting": true, + "required": false, + "editable": true, + "disabled": false, + "since": "1.0.1", + "title": "Analytics", + "label": "Analytics", + "description": "We will store data in an aggregate form about user's experience with RedisInsight. We use these data to fix bugs and improve the experience for all users." + }, + "encryption": { + "conditional": true, + "checker": "KEYTAR", + "defaultOption": "false", + "options": { + "true": { + "defaultValue": true, + "displayInSetting": false, + "required": false, + "editable": true, + "disabled": false, + "since": "1.0.3", + "title": "Encryption", + "label": "Encrypt sensitive information", + "description": "We will encrypt your sensitive information added to the application using the system keychain. Otherwise, this information will be stored locally in plain text and may lead to security risks." + }, + "false": { + "defaultValue": false, + "displayInSetting": false, + "required": false, + "editable": true, + "disabled": true, + "since": "1.0.3", + "title": "Encryption", + "label": "Encrypt sensitive information", + "description": "Install or enable the system keychain to encrypt and securely store your sensitive information added before using the application. Otherwise, this information will be stored locally in plain text and may lead to security risks." + } + } + }, + "eula": { + "defaultValue": false, + "displayInSetting": false, + "required": true, + "editable": false, + "disabled": false, + "since": "1.0.4", + "title": "Server Side Public License", + "label": "I have read and understood the Server Side Public License", + "requiredText": "Accept the Server Side Public License" + } + } +} diff --git a/redisinsight/api/src/constants/app-events.ts b/redisinsight/api/src/constants/app-events.ts new file mode 100644 index 0000000000..96121896d1 --- /dev/null +++ b/redisinsight/api/src/constants/app-events.ts @@ -0,0 +1,4 @@ +export enum AppAnalyticsEvents { + Initialize = 'analytics.initialize', + Track = 'analytics.track', +} diff --git a/redisinsight/api/src/constants/commands/main.json b/redisinsight/api/src/constants/commands/main.json new file mode 100644 index 0000000000..45cb3d0a78 --- /dev/null +++ b/redisinsight/api/src/constants/commands/main.json @@ -0,0 +1,5901 @@ +{ + "ACL LOAD": { + "summary": "Reload the ACLs from the configured ACL file", + "complexity": "O(N). Where N is the number of configured users.", + "since": "6.0.0", + "group": "server" + }, + "ACL SAVE": { + "summary": "Save the current ACL rules in the configured ACL file", + "complexity": "O(N). Where N is the number of configured users.", + "since": "6.0.0", + "group": "server" + }, + "ACL LIST": { + "summary": "List the current ACL rules in ACL config file format", + "complexity": "O(N). Where N is the number of configured users.", + "since": "6.0.0", + "group": "server" + }, + "ACL USERS": { + "summary": "List the username of all the configured ACL rules", + "complexity": "O(N). Where N is the number of configured users.", + "since": "6.0.0", + "group": "server" + }, + "ACL GETUSER": { + "summary": "Get the rules for a specific ACL user", + "complexity": "O(N). Where N is the number of password, command and pattern rules that the user has.", + "arguments": [ + { + "name": "username", + "type": "string" + } + ], + "since": "6.0.0", + "group": "server" + }, + "ACL SETUSER": { + "summary": "Modify or create the rules for a specific ACL user", + "complexity": "O(N). Where N is the number of rules provided.", + "arguments": [ + { + "name": "username", + "type": "string" + }, + { + "name": "rule", + "type": "string", + "multiple": true, + "optional": true + } + ], + "since": "6.0.0", + "group": "server" + }, + "ACL DELUSER": { + "summary": "Remove the specified ACL users and the associated rules", + "complexity": "O(1) amortized time considering the typical user.", + "arguments": [ + { + "name": "username", + "type": "string", + "multiple": true + } + ], + "since": "6.0.0", + "group": "server" + }, + "ACL CAT": { + "summary": "List the ACL categories or the commands inside a category", + "complexity": "O(1) since the categories and commands are a fixed set.", + "arguments": [ + { + "name": "categoryname", + "type": "string", + "optional": true + } + ], + "since": "6.0.0", + "group": "server" + }, + "ACL GENPASS": { + "summary": "Generate a pseudorandom secure password to use for ACL users", + "complexity": "O(1)", + "arguments": [ + { + "name": "bits", + "type": "integer", + "optional": true + } + ], + "since": "6.0.0", + "group": "server" + }, + "ACL WHOAMI": { + "summary": "Return the name of the user associated to the current connection", + "complexity": "O(1)", + "since": "6.0.0", + "group": "server" + }, + "ACL LOG": { + "summary": "List latest events denied because of ACLs in place", + "complexity": "O(N) with N being the number of entries shown.", + "arguments": [ + { + "name": "count or RESET", + "type": "string", + "optional": true + } + ], + "since": "6.0.0", + "group": "server" + }, + "ACL HELP": { + "summary": "Show helpful text about the different subcommands", + "complexity": "O(1)", + "since": "6.0.0", + "group": "server" + }, + "APPEND": { + "summary": "Append a value to a key", + "complexity": "O(1). The amortized time complexity is O(1) assuming the appended value is small and the already present value is of any size, since the dynamic string library used by Redis will double the free space available on every reallocation.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "2.0.0", + "group": "string" + }, + "ASKING": { + "summary": "Sent by cluster clients after an -ASK redirect", + "complexity": "O(1)", + "arguments": [], + "since": "3.0.0", + "group": "cluster" + }, + "AUTH": { + "summary": "Authenticate to the server", + "arguments": [ + { + "name": "username", + "type": "string", + "optional": true + }, + { + "name": "password", + "type": "string" + } + ], + "since": "1.0.0", + "group": "connection" + }, + "BGREWRITEAOF": { + "summary": "Asynchronously rewrite the append-only file", + "since": "1.0.0", + "group": "server" + }, + "BGSAVE": { + "summary": "Asynchronously save the dataset to disk", + "arguments": [ + { + "name": "schedule", + "type": "enum", + "enum": [ + "SCHEDULE" + ], + "optional": true + } + ], + "since": "1.0.0", + "group": "server" + }, + "BITCOUNT": { + "summary": "Count set bits in a string", + "complexity": "O(N)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": [ + "start", + "end" + ], + "type": [ + "integer", + "integer" + ], + "optional": true + } + ], + "since": "2.6.0", + "group": "bitmap" + }, + "BITFIELD": { + "summary": "Perform arbitrary bitfield integer operations on strings", + "complexity": "O(1) for each subcommand specified", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "command": "GET", + "name": [ + "type", + "offset" + ], + "type": [ + "type", + "integer" + ], + "optional": true + }, + { + "command": "SET", + "name": [ + "type", + "offset", + "value" + ], + "type": [ + "type", + "integer", + "integer" + ], + "optional": true + }, + { + "command": "INCRBY", + "name": [ + "type", + "offset", + "increment" + ], + "type": [ + "type", + "integer", + "integer" + ], + "optional": true + }, + { + "command": "OVERFLOW", + "type": "enum", + "enum": [ + "WRAP", + "SAT", + "FAIL" + ], + "optional": true + } + ], + "since": "3.2.0", + "group": "bitmap" + }, + "BITFIELD_RO": { + "summary": "Perform arbitrary bitfield integer operations on strings. Read-only variant of BITFIELD", + "complexity": "O(1) for each subcommand specified", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "command": "GET", + "name": [ + "type", + "offset" + ], + "type": [ + "type", + "integer" + ] + } + ], + "since": "6.2.0", + "group": "bitmap" + }, + "BITOP": { + "summary": "Perform bitwise operations between strings", + "complexity": "O(N)", + "arguments": [ + { + "name": "operation", + "type": "string" + }, + { + "name": "destkey", + "type": "key" + }, + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "2.6.0", + "group": "bitmap" + }, + "BITPOS": { + "summary": "Find first bit set or clear in a string", + "complexity": "O(N)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "bit", + "type": "integer" + }, + { + "name": "index", + "type": "block", + "optional": true, + "block": [ + { + "name": "start", + "type": "integer" + }, + { + "name": "end", + "type": "integer", + "optional": true + } + ] + } + ], + "since": "2.8.7", + "group": "bitmap" + }, + "BLPOP": { + "summary": "Remove and get the first element in a list, or block until one is available", + "complexity": "O(N) where N is the number of provided keys.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "timeout", + "type": "double" + } + ], + "since": "2.0.0", + "group": "list" + }, + "BRPOP": { + "summary": "Remove and get the last element in a list, or block until one is available", + "complexity": "O(N) where N is the number of provided keys.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "timeout", + "type": "double" + } + ], + "since": "2.0.0", + "group": "list" + }, + "BRPOPLPUSH": { + "summary": "Pop an element from a list, push it to another list and return it; or block until one is available", + "complexity": "O(1)", + "arguments": [ + { + "name": "source", + "type": "key" + }, + { + "name": "destination", + "type": "key" + }, + { + "name": "timeout", + "type": "double" + } + ], + "since": "2.2.0", + "group": "list" + }, + "BLMOVE": { + "summary": "Pop an element from a list, push it to another list and return it; or block until one is available", + "complexity": "O(1)", + "arguments": [ + { + "name": "source", + "type": "key" + }, + { + "name": "destination", + "type": "key" + }, + { + "name": "wherefrom", + "type": "enum", + "enum": [ + "LEFT", + "RIGHT" + ] + }, + { + "name": "whereto", + "type": "enum", + "enum": [ + "LEFT", + "RIGHT" + ] + }, + { + "name": "timeout", + "type": "double" + } + ], + "since": "6.2.0", + "group": "list" + }, + "LMPOP": { + "summary": "Pop elements from a list", + "complexity": "O(N+M) where N is the number of provided keys and M is the number of elements returned.", + "arguments": [ + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "optional": true, + "multiple": true + }, + { + "name": "where", + "type": "enum", + "enum": [ + "LEFT", + "RIGHT" + ] + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "7.0.0", + "group": "list" + }, + "BLMPOP": { + "summary": "Pop elements from a list, or block until one is available", + "complexity": "O(N+M) where N is the number of provided keys and M is the number of elements returned.", + "arguments": [ + { + "name": "timeout", + "type": "double" + }, + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "optional": true, + "multiple": true + }, + { + "name": "where", + "type": "enum", + "enum": [ + "LEFT", + "RIGHT" + ] + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "7.0.0", + "group": "list" + }, + "BZPOPMIN": { + "summary": "Remove and return the member with the lowest score from one or more sorted sets, or block until one is available", + "complexity": "O(log(N)) with N being the number of elements in the sorted set.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "timeout", + "type": "double" + } + ], + "since": "5.0.0", + "group": "sorted_set" + }, + "BZPOPMAX": { + "summary": "Remove and return the member with the highest score from one or more sorted sets, or block until one is available", + "complexity": "O(log(N)) with N being the number of elements in the sorted set.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "timeout", + "type": "double" + } + ], + "since": "5.0.0", + "group": "sorted_set" + }, + "CLIENT CACHING": { + "summary": "Instruct the server about tracking or not keys in the next request", + "complexity": "O(1)", + "arguments": [ + { + "name": "mode", + "type": "enum", + "enum": [ + "YES", + "NO" + ] + } + ], + "since": "6.0.0", + "group": "connection" + }, + "CLIENT ID": { + "summary": "Returns the client ID for the current connection", + "complexity": "O(1)", + "since": "5.0.0", + "group": "connection" + }, + "CLIENT INFO": { + "summary": "Returns information about the current client connection.", + "complexity": "O(1)", + "since": "6.2.0", + "group": "connection" + }, + "CLIENT KILL": { + "summary": "Kill the connection of a client", + "complexity": "O(N) where N is the number of client connections", + "arguments": [ + { + "name": "ip:port", + "type": "string", + "optional": true + }, + { + "command": "ID", + "name": "client-id", + "type": "integer", + "optional": true + }, + { + "command": "TYPE", + "type": "enum", + "enum": [ + "normal", + "master", + "slave", + "pubsub" + ], + "optional": true + }, + { + "command": "USER", + "name": "username", + "type": "string", + "optional": true + }, + { + "command": "ADDR", + "name": "ip:port", + "type": "string", + "optional": true + }, + { + "command": "LADDR", + "name": "ip:port", + "type": "string", + "optional": true + }, + { + "command": "SKIPME", + "name": "yes/no", + "type": "string", + "optional": true + } + ], + "since": "2.4.0", + "group": "connection" + }, + "CLIENT LIST": { + "summary": "Get the list of client connections", + "complexity": "O(N) where N is the number of client connections", + "arguments": [ + { + "command": "TYPE", + "type": "enum", + "enum": [ + "normal", + "master", + "replica", + "pubsub" + ], + "optional": true + }, + { + "name": "id", + "type": "block", + "block": [ + { + "command": "ID" + }, + { + "name": "client-id", + "type": "integer", + "multiple": true + } + ], + "optional": true + } + ], + "since": "2.4.0", + "group": "connection" + }, + "CLIENT GETNAME": { + "summary": "Get the current connection name", + "complexity": "O(1)", + "since": "2.6.9", + "group": "connection" + }, + "CLIENT GETREDIR": { + "summary": "Get tracking notifications redirection client ID if any", + "complexity": "O(1)", + "since": "6.0.0", + "group": "connection" + }, + "CLIENT UNPAUSE": { + "summary": "Resume processing of clients that were paused", + "complexity": "O(N) Where N is the number of paused clients", + "since": "6.2.0", + "group": "connection" + }, + "CLIENT PAUSE": { + "summary": "Stop processing commands from clients for some time", + "complexity": "O(1)", + "arguments": [ + { + "name": "timeout", + "type": "integer" + }, + { + "name": "mode", + "type": "enum", + "optional": true, + "enum": [ + "WRITE", + "ALL" + ] + } + ], + "since": "2.9.50", + "group": "connection" + }, + "CLIENT REPLY": { + "summary": "Instruct the server whether to reply to commands", + "complexity": "O(1)", + "arguments": [ + { + "name": "reply-mode", + "type": "enum", + "enum": [ + "ON", + "OFF", + "SKIP" + ] + } + ], + "since": "3.2.0", + "group": "connection" + }, + "CLIENT SETNAME": { + "summary": "Set the current connection name", + "complexity": "O(1)", + "since": "2.6.9", + "arguments": [ + { + "name": "connection-name", + "type": "string" + } + ], + "group": "connection" + }, + "CLIENT TRACKING": { + "summary": "Enable or disable server assisted client side caching support", + "complexity": "O(1). Some options may introduce additional complexity.", + "arguments": [ + { + "name": "status", + "type": "enum", + "enum": [ + "ON", + "OFF" + ] + }, + { + "command": "REDIRECT", + "name": "client-id", + "type": "integer", + "optional": true + }, + { + "command": "PREFIX", + "name": "prefix", + "type": "string", + "optional": true, + "multiple": true + }, + { + "name": "BCAST", + "type": "enum", + "enum": [ + "BCAST" + ], + "optional": true + }, + { + "name": "OPTIN", + "type": "enum", + "enum": [ + "OPTIN" + ], + "optional": true + }, + { + "name": "OPTOUT", + "type": "enum", + "enum": [ + "OPTOUT" + ], + "optional": true + }, + { + "name": "NOLOOP", + "type": "enum", + "enum": [ + "NOLOOP" + ], + "optional": true + } + ], + "since": "6.0.0", + "group": "connection" + }, + "CLIENT TRACKINGINFO": { + "summary": "Return information about server assisted client side caching for the current connection", + "complexity": "O(1)", + "since": "6.2.0", + "group": "connection" + }, + "CLIENT UNBLOCK": { + "summary": "Unblock a client blocked in a blocking command from a different connection", + "complexity": "O(log N) where N is the number of client connections", + "arguments": [ + { + "name": "client-id", + "type": "integer" + }, + { + "name": "unblock-type", + "type": "enum", + "enum": [ + "TIMEOUT", + "ERROR" + ], + "optional": true + } + ], + "since": "5.0.0", + "group": "connection" + }, + "CLIENT NO-EVICT": { + "summary": "Set client eviction mode for the current connection", + "complexity": "O(1)", + "since": "7.0.0", + "arguments": [ + { + "name": "enabled", + "type": "enum", + "enum": [ + "ON", + "OFF" + ] + } + ], + "group": "connection" + }, + "CLUSTER ADDSLOTS": { + "summary": "Assign new hash slots to receiving node", + "complexity": "O(N) where N is the total number of hash slot arguments", + "arguments": [ + { + "name": "slot", + "type": "integer", + "multiple": true + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER BUMPEPOCH": { + "summary": "Advance the cluster config epoch", + "complexity": "O(1)", + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER COUNT-FAILURE-REPORTS": { + "summary": "Return the number of failure reports active for a given node", + "complexity": "O(N) where N is the number of failure reports", + "arguments": [ + { + "name": "node-id", + "type": "string" + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER COUNTKEYSINSLOT": { + "summary": "Return the number of local keys in the specified hash slot", + "complexity": "O(1)", + "arguments": [ + { + "name": "slot", + "type": "integer" + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER DELSLOTS": { + "summary": "Set hash slots as unbound in receiving node", + "complexity": "O(N) where N is the total number of hash slot arguments", + "arguments": [ + { + "name": "slot", + "type": "integer", + "multiple": true + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER FAILOVER": { + "summary": "Forces a replica to perform a manual failover of its master.", + "complexity": "O(1)", + "arguments": [ + { + "name": "options", + "type": "enum", + "enum": [ + "FORCE", + "TAKEOVER" + ], + "optional": true + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER FLUSHSLOTS": { + "summary": "Delete a node's own slots information", + "complexity": "O(1)", + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER FORGET": { + "summary": "Remove a node from the nodes table", + "complexity": "O(1)", + "arguments": [ + { + "name": "node-id", + "type": "string" + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER GETKEYSINSLOT": { + "summary": "Return local key names in the specified hash slot", + "complexity": "O(log(N)) where N is the number of requested keys", + "arguments": [ + { + "name": "slot", + "type": "integer" + }, + { + "name": "count", + "type": "integer" + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER INFO": { + "summary": "Provides info about Redis Cluster node state", + "complexity": "O(1)", + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER KEYSLOT": { + "summary": "Returns the hash slot of the specified key", + "complexity": "O(N) where N is the number of bytes in the key", + "arguments": [ + { + "name": "key", + "type": "string" + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER MEET": { + "summary": "Force a node cluster to handshake with another node", + "complexity": "O(1)", + "arguments": [ + { + "name": "ip", + "type": "string" + }, + { + "name": "port", + "type": "integer" + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER MYID": { + "summary": "Return the node id", + "complexity": "O(1)", + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER NODES": { + "summary": "Get Cluster config for the node", + "complexity": "O(N) where N is the total number of Cluster nodes", + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER REPLICATE": { + "summary": "Reconfigure a node as a replica of the specified master node", + "complexity": "O(1)", + "arguments": [ + { + "name": "node-id", + "type": "string" + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER RESET": { + "summary": "Reset a Redis Cluster node", + "complexity": "O(N) where N is the number of known nodes. The command may execute a FLUSHALL as a side effect.", + "arguments": [ + { + "name": "reset-type", + "type": "enum", + "enum": [ + "HARD", + "SOFT" + ], + "optional": true + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER SAVECONFIG": { + "summary": "Forces the node to save cluster state on disk", + "complexity": "O(1)", + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER SET-CONFIG-EPOCH": { + "summary": "Set the configuration epoch in a new node", + "complexity": "O(1)", + "arguments": [ + { + "name": "config-epoch", + "type": "integer" + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER SETSLOT": { + "summary": "Bind a hash slot to a specific node", + "complexity": "O(1)", + "arguments": [ + { + "name": "slot", + "type": "integer" + }, + { + "name": "subcommand", + "type": "enum", + "enum": [ + "IMPORTING", + "MIGRATING", + "STABLE", + "NODE" + ] + }, + { + "name": "node-id", + "type": "string", + "optional": true + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER SLAVES": { + "summary": "List replica nodes of the specified master node", + "complexity": "O(1)", + "arguments": [ + { + "name": "node-id", + "type": "string" + } + ], + "since": "3.0.0", + "group": "cluster" + }, + "CLUSTER REPLICAS": { + "summary": "List replica nodes of the specified master node", + "complexity": "O(1)", + "arguments": [ + { + "name": "node-id", + "type": "string" + } + ], + "since": "5.0.0", + "group": "cluster" + }, + "CLUSTER SLOTS": { + "summary": "Get array of Cluster slot to node mappings", + "complexity": "O(N) where N is the total number of Cluster nodes", + "since": "3.0.0", + "group": "cluster" + }, + "COMMAND": { + "summary": "Get array of Redis command details", + "complexity": "O(N) where N is the total number of Redis commands", + "since": "2.8.13", + "group": "server" + }, + "COMMAND COUNT": { + "summary": "Get total number of Redis commands", + "complexity": "O(1)", + "since": "2.8.13", + "group": "server" + }, + "COMMAND GETKEYS": { + "summary": "Extract keys given a full Redis command", + "complexity": "O(N) where N is the number of arguments to the command", + "since": "2.8.13", + "group": "server" + }, + "COMMAND INFO": { + "summary": "Get array of specific Redis command details", + "complexity": "O(N) when N is number of commands to look up", + "since": "2.8.13", + "arguments": [ + { + "name": "command-name", + "type": "string", + "multiple": true + } + ], + "group": "server" + }, + "CONFIG GET": { + "summary": "Get the value of a configuration parameter", + "arguments": [ + { + "name": "parameter", + "type": "string" + } + ], + "since": "2.0.0", + "group": "server" + }, + "CONFIG REWRITE": { + "summary": "Rewrite the configuration file with the in memory configuration", + "since": "2.8.0", + "group": "server" + }, + "CONFIG SET": { + "summary": "Set a configuration parameter to the given value", + "arguments": [ + { + "name": "parameter", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "2.0.0", + "group": "server" + }, + "CONFIG RESETSTAT": { + "summary": "Reset the stats returned by INFO", + "complexity": "O(1)", + "since": "2.0.0", + "group": "server" + }, + "COPY": { + "summary": "Copy a key", + "complexity": "O(N) worst case for collections, where N is the number of nested items. O(1) for string values.", + "since": "6.2.0", + "arguments": [ + { + "name": "source", + "type": "key" + }, + { + "name": "destination", + "type": "key" + }, + { + "command": "DB", + "name": "destination-db", + "type": "integer", + "optional": true + }, + { + "name": "replace", + "type": "enum", + "enum": [ + "REPLACE" + ], + "optional": true + } + ], + "group": "generic" + }, + "DBSIZE": { + "summary": "Return the number of keys in the selected database", + "since": "1.0.0", + "group": "server" + }, + "DEBUG OBJECT": { + "summary": "Get debugging information about a key", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "server" + }, + "DEBUG SEGFAULT": { + "summary": "Make the server crash", + "since": "1.0.0", + "group": "server" + }, + "DECR": { + "summary": "Decrement the integer value of a key by one", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "string" + }, + "DECRBY": { + "summary": "Decrement the integer value of a key by the given number", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "decrement", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "string" + }, + "DEL": { + "summary": "Delete a key", + "complexity": "O(N) where N is the number of keys that will be removed. When a key to remove holds a value other than a string, the individual complexity for this key is O(M) where M is the number of elements in the list, set, sorted set or hash. Removing a single key that holds a string value is O(1).", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "1.0.0", + "group": "generic" + }, + "DISCARD": { + "summary": "Discard all commands issued after MULTI", + "since": "2.0.0", + "group": "transactions" + }, + "DUMP": { + "summary": "Return a serialized version of the value stored at the specified key.", + "complexity": "O(1) to access the key and additional O(N*M) to serialize it, where N is the number of Redis objects composing the value and M their average size. For small string values the time complexity is thus O(1)+O(1*M) where M is small, so simply O(1).", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.6.0", + "group": "generic" + }, + "ECHO": { + "summary": "Echo the given string", + "arguments": [ + { + "name": "message", + "type": "string" + } + ], + "since": "1.0.0", + "group": "connection" + }, + "EVAL": { + "summary": "Execute a Lua script server side", + "complexity": "Depends on the script that is executed.", + "arguments": [ + { + "name": "script", + "type": "string" + }, + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "optional": true, + "multiple": true + }, + { + "name": "arg", + "type": "string", + "optional": true, + "multiple": true + } + ], + "since": "2.6.0", + "group": "scripting" + }, + "EVAL_RO": { + "summary": "Execute a read-only Lua script server side", + "complexity": "Depends on the script that is executed.", + "arguments": [ + { + "name": "script", + "type": "string" + }, + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "arg", + "type": "string", + "multiple": true + } + ], + "since": "7.0.0", + "group": "scripting" + }, + "EVALSHA": { + "summary": "Execute a Lua script server side", + "complexity": "Depends on the script that is executed.", + "arguments": [ + { + "name": "sha1", + "type": "string" + }, + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "optional": true, + "multiple": true + }, + { + "name": "arg", + "type": "string", + "optional": true, + "multiple": true + } + ], + "since": "2.6.0", + "group": "scripting" + }, + "EVALSHA_RO": { + "summary": "Execute a read-only Lua script server side", + "complexity": "Depends on the script that is executed.", + "arguments": [ + { + "name": "sha1", + "type": "string" + }, + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "arg", + "type": "string", + "multiple": true + } + ], + "since": "7.0.0", + "group": "scripting" + }, + "EXEC": { + "summary": "Execute all commands issued after MULTI", + "since": "1.2.0", + "group": "transactions" + }, + "EXISTS": { + "summary": "Determine if a key exists", + "complexity": "O(N) where N is the number of keys to check.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "1.0.0", + "group": "generic" + }, + "EXPIRE": { + "summary": "Set a key's time to live in seconds", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "seconds", + "type": "integer" + }, + { + "name": "condition", + "type": "enum", + "enum": [ + "NX", + "XX", + "GT", + "LT" + ], + "optional": true + } + ], + "since": "1.0.0", + "group": "generic" + }, + "EXPIREAT": { + "summary": "Set the expiration for a key as a UNIX timestamp", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "timestamp", + "type": "posix time" + }, + { + "name": "condition", + "type": "enum", + "enum": [ + "NX", + "XX", + "GT", + "LT" + ], + "optional": true + } + ], + "since": "1.2.0", + "group": "generic" + }, + "EXPIRETIME": { + "summary": "Get the expiration Unix timestamp for a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "7.0.0", + "group": "generic" + }, + "FAILOVER": { + "summary": "Start a coordinated failover between this server and one of its replicas.", + "arguments": [ + { + "name": "target", + "type": "block", + "optional": true, + "block": [ + { + "command": "TO" + }, + { + "name": "host", + "type": "string" + }, + { + "name": "port", + "type": "integer" + }, + { + "command": "FORCE", + "optional": true + } + ] + }, + { + "command": "ABORT", + "optional": true + }, + { + "command": "TIMEOUT", + "name": "milliseconds", + "type": "integer", + "optional": true + } + ], + "since": "6.2.0", + "group": "server" + }, + "FLUSHALL": { + "summary": "Remove all keys from all databases", + "complexity": "O(N) where N is the total number of keys in all databases", + "arguments": [ + { + "name": "async", + "type": "enum", + "enum": [ + "ASYNC", + "SYNC" + ], + "optional": true + } + ], + "since": "1.0.0", + "group": "server" + }, + "FLUSHDB": { + "summary": "Remove all keys from the current database", + "complexity": "O(N) where N is the number of keys in the selected database", + "arguments": [ + { + "name": "async", + "type": "enum", + "enum": [ + "ASYNC", + "SYNC" + ], + "optional": true + } + ], + "since": "1.0.0", + "group": "server" + }, + "GEOADD": { + "summary": "Add one or more geospatial items in the geospatial index represented using a sorted set", + "complexity": "O(log(N)) for each item added, where N is the number of elements in the sorted set.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "condition", + "type": "enum", + "enum": [ + "NX", + "XX" + ], + "optional": true + }, + { + "name": "change", + "type": "enum", + "enum": [ + "CH" + ], + "optional": true + }, + { + "name": [ + "longitude", + "latitude", + "member" + ], + "type": [ + "double", + "double", + "string" + ], + "multiple": true + } + ], + "since": "3.2.0", + "group": "geo" + }, + "GEOHASH": { + "summary": "Returns members of a geospatial index as standard geohash strings", + "complexity": "O(log(N)) for each member requested, where N is the number of elements in the sorted set.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string", + "multiple": true + } + ], + "since": "3.2.0", + "group": "geo" + }, + "GEOPOS": { + "summary": "Returns longitude and latitude of members of a geospatial index", + "complexity": "O(N) where N is the number of members requested.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string", + "multiple": true + } + ], + "since": "3.2.0", + "group": "geo" + }, + "GEODIST": { + "summary": "Returns the distance between two members of a geospatial index", + "complexity": "O(log(N))", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member1", + "type": "string" + }, + { + "name": "member2", + "type": "string" + }, + { + "name": "unit", + "type": "enum", + "enum": [ + "m", + "km", + "ft", + "mi" + ], + "optional": true + } + ], + "since": "3.2.0", + "group": "geo" + }, + "GEORADIUS": { + "summary": "Query a sorted set representing a geospatial index to fetch members matching a given maximum distance from a point", + "complexity": "O(N+log(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "longitude", + "type": "double" + }, + { + "name": "latitude", + "type": "double" + }, + { + "name": "radius", + "type": "double" + }, + { + "name": "unit", + "type": "enum", + "enum": [ + "m", + "km", + "ft", + "mi" + ] + }, + { + "name": "withcoord", + "type": "enum", + "enum": [ + "WITHCOORD" + ], + "optional": true + }, + { + "name": "withdist", + "type": "enum", + "enum": [ + "WITHDIST" + ], + "optional": true + }, + { + "name": "withhash", + "type": "enum", + "enum": [ + "WITHHASH" + ], + "optional": true + }, + { + "type": "block", + "name": "count", + "block": [ + { + "name": "count", + "command": "COUNT", + "type": "integer" + }, + { + "name": "any", + "type": "enum", + "enum": [ + "ANY" + ], + "optional": true + } + ], + "optional": true + }, + { + "name": "order", + "type": "enum", + "enum": [ + "ASC", + "DESC" + ], + "optional": true + }, + { + "command": "STORE", + "name": "key", + "type": "key", + "optional": true + }, + { + "command": "STOREDIST", + "name": "key", + "type": "key", + "optional": true + } + ], + "since": "3.2.0", + "group": "geo" + }, + "GEORADIUSBYMEMBER": { + "summary": "Query a sorted set representing a geospatial index to fetch members matching a given maximum distance from a member", + "complexity": "O(N+log(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string" + }, + { + "name": "radius", + "type": "double" + }, + { + "name": "unit", + "type": "enum", + "enum": [ + "m", + "km", + "ft", + "mi" + ] + }, + { + "name": "withcoord", + "type": "enum", + "enum": [ + "WITHCOORD" + ], + "optional": true + }, + { + "name": "withdist", + "type": "enum", + "enum": [ + "WITHDIST" + ], + "optional": true + }, + { + "name": "withhash", + "type": "enum", + "enum": [ + "WITHHASH" + ], + "optional": true + }, + { + "type": "block", + "name": "count", + "block": [ + { + "name": "count", + "command": "COUNT", + "type": "integer" + }, + { + "name": "any", + "type": "enum", + "enum": [ + "ANY" + ], + "optional": true + } + ], + "optional": true + }, + { + "name": "order", + "type": "enum", + "enum": [ + "ASC", + "DESC" + ], + "optional": true + }, + { + "command": "STORE", + "name": "key", + "type": "key", + "optional": true + }, + { + "command": "STOREDIST", + "name": "key", + "type": "key", + "optional": true + } + ], + "since": "3.2.0", + "group": "geo" + }, + "GEOSEARCH": { + "summary": "Query a sorted set representing a geospatial index to fetch members inside an area of a box or a circle.", + "complexity": "O(N+log(M)) where N is the number of elements in the grid-aligned bounding box area around the shape provided as the filter and M is the number of items inside the shape", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "command": "FROMMEMBER", + "name": "member", + "type": "string", + "optional": true + }, + { + "command": "FROMLONLAT", + "name": [ + "longitude", + "latitude" + ], + "type": [ + "double", + "double" + ], + "optional": true + }, + { + "type": "block", + "name": "circle", + "block": [ + { + "name": "radius", + "command": "BYRADIUS", + "type": "double" + }, + { + "name": "unit", + "type": "enum", + "enum": [ + "m", + "km", + "ft", + "mi" + ] + } + ], + "optional": true + }, + { + "type": "block", + "name": "box", + "block": [ + { + "name": "width", + "command": "BYBOX", + "type": "double" + }, + { + "name": "height", + "type": "double" + }, + { + "name": "unit", + "type": "enum", + "enum": [ + "m", + "km", + "ft", + "mi" + ] + } + ], + "optional": true + }, + { + "name": "order", + "type": "enum", + "enum": [ + "ASC", + "DESC" + ], + "optional": true + }, + { + "type": "block", + "name": "count", + "block": [ + { + "name": "count", + "command": "COUNT", + "type": "integer" + }, + { + "name": "any", + "type": "enum", + "enum": [ + "ANY" + ], + "optional": true + } + ], + "optional": true + }, + { + "name": "withcoord", + "type": "enum", + "enum": [ + "WITHCOORD" + ], + "optional": true + }, + { + "name": "withdist", + "type": "enum", + "enum": [ + "WITHDIST" + ], + "optional": true + }, + { + "name": "withhash", + "type": "enum", + "enum": [ + "WITHHASH" + ], + "optional": true + } + ], + "since": "6.2", + "group": "geo" + }, + "GEOSEARCHSTORE": { + "summary": "Query a sorted set representing a geospatial index to fetch members inside an area of a box or a circle, and store the result in another key.", + "complexity": "O(N+log(M)) where N is the number of elements in the grid-aligned bounding box area around the shape provided as the filter and M is the number of items inside the shape", + "arguments": [ + { + "name": "destination", + "type": "key" + }, + { + "name": "source", + "type": "key" + }, + { + "command": "FROMMEMBER", + "name": "member", + "type": "string", + "optional": true + }, + { + "command": "FROMLONLAT", + "name": [ + "longitude", + "latitude" + ], + "type": [ + "double", + "double" + ], + "optional": true + }, + { + "type": "block", + "name": "circle", + "block": [ + { + "name": "radius", + "command": "BYRADIUS", + "type": "double" + }, + { + "name": "unit", + "type": "enum", + "enum": [ + "m", + "km", + "ft", + "mi" + ] + } + ], + "optional": true + }, + { + "type": "block", + "name": "box", + "block": [ + { + "name": "width", + "command": "BYBOX", + "type": "double" + }, + { + "name": "height", + "type": "double" + }, + { + "name": "unit", + "type": "enum", + "enum": [ + "m", + "km", + "ft", + "mi" + ] + } + ], + "optional": true + }, + { + "name": "order", + "type": "enum", + "enum": [ + "ASC", + "DESC" + ], + "optional": true + }, + { + "type": "block", + "name": "count", + "block": [ + { + "name": "count", + "command": "COUNT", + "type": "integer" + }, + { + "name": "any", + "type": "enum", + "enum": [ + "ANY" + ], + "optional": true + } + ], + "optional": true + }, + { + "name": "storedist", + "type": "enum", + "enum": [ + "STOREDIST" + ], + "optional": true + } + ], + "since": "6.2", + "group": "geo" + }, + "GET": { + "summary": "Get the value of a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "string" + }, + "GETBIT": { + "summary": "Returns the bit value at offset in the string value stored at key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "offset", + "type": "integer" + } + ], + "since": "2.2.0", + "group": "bitmap" + }, + "GETDEL": { + "summary":"Get the value of a key and delete the key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "6.2.0", + "group": "string" + }, + "GETEX": { + "summary": "Get the value of a key and optionally set its expiration", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "expiration", + "type": "enum", + "enum": [ + "EX seconds", + "PX milliseconds", + "EXAT timestamp", + "PXAT milliseconds-timestamp", + "PERSIST" + ], + "optional": true + } + ], + "since": "6.2.0", + "group": "string" + }, + "GETRANGE": { + "summary": "Get a substring of the string stored at a key", + "complexity": "O(N) where N is the length of the returned string. The complexity is ultimately determined by the returned length, but because creating a substring from an existing string is very cheap, it can be considered O(1) for small strings.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "start", + "type": "integer" + }, + { + "name": "end", + "type": "integer" + } + ], + "since": "2.4.0", + "group": "string" + }, + "GETSET": { + "summary": "Set the string value of a key and return its old value", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "1.0.0", + "group": "string" + }, + "HDEL": { + "summary": "Delete one or more hash fields", + "complexity": "O(N) where N is the number of fields to be removed.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "field", + "type": "string", + "multiple": true + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HELLO": { + "summary": "Handshake with Redis", + "complexity": "O(1)", + "arguments": [ + { + "name": "arguments", + "type": "block", + "block": [ + { + "name": "protover", + "type": "integer" + }, + { + "command": "AUTH", + "name": [ + "username", + "password" + ], + "type": [ + "string", + "string" + ], + "optional": true + }, + { + "command": "SETNAME", + "name": "clientname", + "type": "string", + "optional": true + } + ], + "optional": true + } + ], + "since": "6.0.0", + "group": "connection" + }, + "HEXISTS": { + "summary": "Determine if a hash field exists", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "field", + "type": "string" + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HGET": { + "summary": "Get the value of a hash field", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "field", + "type": "string" + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HGETALL": { + "summary": "Get all the fields and values in a hash", + "complexity": "O(N) where N is the size of the hash.", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HINCRBY": { + "summary": "Increment the integer value of a hash field by the given number", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "field", + "type": "string" + }, + { + "name": "increment", + "type": "integer" + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HINCRBYFLOAT": { + "summary": "Increment the float value of a hash field by the given amount", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "field", + "type": "string" + }, + { + "name": "increment", + "type": "double" + } + ], + "since": "2.6.0", + "group": "hash" + }, + "HKEYS": { + "summary": "Get all the fields in a hash", + "complexity": "O(N) where N is the size of the hash.", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HLEN": { + "summary": "Get the number of fields in a hash", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HMGET": { + "summary": "Get the values of all the given hash fields", + "complexity": "O(N) where N is the number of fields being requested.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "field", + "type": "string", + "multiple": true + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HMSET": { + "summary": "Set multiple hash fields to multiple values", + "complexity": "O(N) where N is the number of fields being set.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": [ + "field", + "value" + ], + "type": [ + "string", + "string" + ], + "multiple": true + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HSET": { + "summary": "Set the string value of a hash field", + "complexity": "O(1) for each field/value pair added, so O(N) to add N field/value pairs when the command is called with multiple field/value pairs.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": [ + "field", + "value" + ], + "type": [ + "string", + "string" + ], + "multiple": true + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HSETNX": { + "summary": "Set the value of a hash field, only if the field does not exist", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "field", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "2.0.0", + "group": "hash" + }, + "HRANDFIELD": { + "summary": "Get one or multiple random fields from a hash", + "complexity": "O(N) where N is the number of fields returned", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "options", + "type": "block", + "block": [ + { + "name": "count", + "type": "integer" + }, + { + "name": "withvalues", + "type": "enum", + "enum": [ + "WITHVALUES" + ], + "optional": true + } + ], + "optional": true + } + ], + "since": "6.2.0", + "group": "hash" + }, + "HSTRLEN": { + "summary": "Get the length of the value of a hash field", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "field", + "type": "string" + } + ], + "since": "3.2.0", + "group": "hash" + }, + "HVALS": { + "summary": "Get all the values in a hash", + "complexity": "O(N) where N is the size of the hash.", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.0.0", + "group": "hash" + }, + "INCR": { + "summary": "Increment the integer value of a key by one", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "string" + }, + "INCRBY": { + "summary": "Increment the integer value of a key by the given amount", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "increment", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "string" + }, + "INCRBYFLOAT": { + "summary": "Increment the float value of a key by the given amount", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "increment", + "type": "double" + } + ], + "since": "2.6.0", + "group": "string" + }, + "INFO": { + "summary": "Get information and statistics about the server", + "arguments": [ + { + "name": "section", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "server" + }, + "LOLWUT": { + "summary": "Display some computer art and the Redis version", + "arguments": [ + { + "command": "VERSION", + "name": "version", + "type": "integer", + "optional": true + } + ], + "since": "5.0.0", + "group": "server" + }, + "KEYS": { + "summary": "Find all keys matching the given pattern", + "complexity": "O(N) with N being the number of keys in the database, under the assumption that the key names in the database and the given pattern have limited length.", + "arguments": [ + { + "name": "pattern", + "type": "pattern" + } + ], + "since": "1.0.0", + "group": "generic" + }, + "LASTSAVE": { + "summary": "Get the UNIX time stamp of the last successful save to disk", + "since": "1.0.0", + "group": "server" + }, + "LINDEX": { + "summary": "Get an element from a list by its index", + "complexity": "O(N) where N is the number of elements to traverse to get to the element at index. This makes asking for the first or the last element of the list O(1).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "index", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "list" + }, + "LINSERT": { + "summary": "Insert an element before or after another element in a list", + "complexity": "O(N) where N is the number of elements to traverse before seeing the value pivot. This means that inserting somewhere on the left end on the list (head) can be considered O(1) and inserting somewhere on the right end (tail) is O(N).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "where", + "type": "enum", + "enum": [ + "BEFORE", + "AFTER" + ] + }, + { + "name": "pivot", + "type": "string" + }, + { + "name": "element", + "type": "string" + } + ], + "since": "2.2.0", + "group": "list" + }, + "LLEN": { + "summary": "Get the length of a list", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "list" + }, + "LPOP": { + "summary": "Remove and get the first elements in a list", + "complexity": "O(N) where N is the number of elements returned", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "1.0.0", + "group": "list" + }, + "LPOS": { + "summary": "Return the index of matching elements on a list", + "complexity": "O(N) where N is the number of elements in the list, for the average case. When searching for elements near the head or the tail of the list, or when the MAXLEN option is provided, the command may run in constant time.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "element", + "type": "string" + }, + { + "command": "RANK", + "name": "rank", + "type": "integer", + "optional": true + }, + { + "command": "COUNT", + "name": "num-matches", + "type": "integer", + "optional": true + }, + { + "command": "MAXLEN", + "name": "len", + "type": "integer", + "optional": true + } + ], + "since": "6.0.6", + "group": "list" + }, + "LPUSH": { + "summary": "Prepend one or multiple elements to a list", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "element", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "list" + }, + "LPUSHX": { + "summary": "Prepend an element to a list, only if the list exists", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "element", + "type": "string", + "multiple": true + } + ], + "since": "2.2.0", + "group": "list" + }, + "LRANGE": { + "summary": "Get a range of elements from a list", + "complexity": "O(S+N) where S is the distance of start offset from HEAD for small lists, from nearest end (HEAD or TAIL) for large lists; and N is the number of elements in the specified range.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "start", + "type": "integer" + }, + { + "name": "stop", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "list" + }, + "LREM": { + "summary": "Remove elements from a list", + "complexity": "O(N+M) where N is the length of the list and M is the number of elements removed.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "element", + "type": "string" + } + ], + "since": "1.0.0", + "group": "list" + }, + "LSET": { + "summary": "Set the value of an element in a list by its index", + "complexity": "O(N) where N is the length of the list. Setting either the first or the last element of the list is O(1).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "index", + "type": "integer" + }, + { + "name": "element", + "type": "string" + } + ], + "since": "1.0.0", + "group": "list" + }, + "LTRIM": { + "summary": "Trim a list to the specified range", + "complexity": "O(N) where N is the number of elements to be removed by the operation.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "start", + "type": "integer" + }, + { + "name": "stop", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "list" + }, + "MEMORY DOCTOR": { + "summary": "Outputs memory problems report", + "since": "4.0.0", + "group": "server" + }, + "MEMORY HELP": { + "summary": "Show helpful text about the different subcommands", + "since": "4.0.0", + "group": "server" + }, + "MEMORY MALLOC-STATS": { + "summary": "Show allocator internal stats", + "since": "4.0.0", + "group": "server" + }, + "MEMORY PURGE": { + "summary": "Ask the allocator to release memory", + "since": "4.0.0", + "group": "server" + }, + "MEMORY STATS": { + "summary": "Show memory usage details", + "since": "4.0.0", + "group": "server" + }, + "MEMORY USAGE": { + "summary": "Estimate the memory usage of a key", + "complexity": "O(N) where N is the number of samples.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "command": "SAMPLES", + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "4.0.0", + "group": "server" + }, + "MGET": { + "summary": "Get the values of all the given keys", + "complexity": "O(N) where N is the number of keys to retrieve.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "1.0.0", + "group": "string" + }, + "MIGRATE": { + "summary": "Atomically transfer a key from a Redis instance to another one.", + "complexity": "This command actually executes a DUMP+DEL in the source instance, and a RESTORE in the target instance. See the pages of these commands for time complexity. Also an O(N) data transfer between the two instances is performed.", + "arguments": [ + { + "name": "host", + "type": "string" + }, + { + "name": "port", + "type": "string" + }, + { + "name": "key", + "type": "enum", + "enum": [ + "key", + "\"\"" + ] + }, + { + "name": "destination-db", + "type": "integer" + }, + { + "name": "timeout", + "type": "integer" + }, + { + "name": "copy", + "type": "enum", + "enum": [ + "COPY" + ], + "optional": true + }, + { + "name": "replace", + "type": "enum", + "enum": [ + "REPLACE" + ], + "optional": true + }, + { + "command": "AUTH", + "name": "password", + "type": "string", + "optional": true + }, + { + "command": "AUTH2", + "name": "username password", + "type": "string", + "optional": true + }, + { + "name": "key", + "command": "KEYS", + "type": "key", + "variadic": true, + "optional": true + } + ], + "since": "2.6.0", + "group": "generic" + }, + "MODULE LIST": { + "summary": "List all modules loaded by the server", + "complexity": "O(N) where N is the number of loaded modules.", + "since": "4.0.0", + "group": "server" + }, + "MODULE LOAD": { + "summary": "Load a module", + "complexity": "O(1)", + "arguments": [ + { + "name": "path", + "type": "string" + }, + { + "name": "arg", + "type": "string", + "variadic": true, + "optional": true + } + ], + "since": "4.0.0", + "group": "server" + }, + "MODULE UNLOAD": { + "summary": "Unload a module", + "complexity": "O(1)", + "arguments": [ + { + "name": "name", + "type": "string" + } + ], + "since": "4.0.0", + "group": "server" + }, + "MONITOR": { + "summary": "Listen for all requests received by the server in real time", + "since": "1.0.0", + "group": "server" + }, + "MOVE": { + "summary": "Move a key to another database", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "db", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "generic" + }, + "MSET": { + "summary": "Set multiple keys to multiple values", + "complexity": "O(N) where N is the number of keys to set.", + "arguments": [ + { + "name": [ + "key", + "value" + ], + "type": [ + "key", + "string" + ], + "multiple": true + } + ], + "since": "1.0.1", + "group": "string" + }, + "MSETNX": { + "summary": "Set multiple keys to multiple values, only if none of the keys exist", + "complexity": "O(N) where N is the number of keys to set.", + "arguments": [ + { + "name": [ + "key", + "value" + ], + "type": [ + "key", + "string" + ], + "multiple": true + } + ], + "since": "1.0.1", + "group": "string" + }, + "MULTI": { + "summary": "Mark the start of a transaction block", + "since": "1.2.0", + "group": "transactions" + }, + "OBJECT": { + "summary": "Inspect the internals of Redis objects", + "complexity": "O(1) for all the currently implemented subcommands.", + "since": "2.2.3", + "group": "generic", + "arguments": [ + { + "name": "subcommand", + "type": "string" + }, + { + "name": "arguments", + "type": "string", + "optional": true, + "multiple": true + } + ] + }, + "PERSIST": { + "summary": "Remove the expiration from a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.2.0", + "group": "generic" + }, + "PEXPIRE": { + "summary": "Set a key's time to live in milliseconds", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "milliseconds", + "type": "integer" + }, + { + "name": "condition", + "type": "enum", + "enum": [ + "NX", + "XX", + "GT", + "LT" + ], + "optional": true + } + ], + "since": "2.6.0", + "group": "generic" + }, + "PEXPIREAT": { + "summary": "Set the expiration for a key as a UNIX timestamp specified in milliseconds", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "milliseconds-timestamp", + "type": "posix time" + }, + { + "name": "condition", + "type": "enum", + "enum": [ + "NX", + "XX", + "GT", + "LT" + ], + "optional": true + } + ], + "since": "2.6.0", + "group": "generic" + }, + "PEXPIRETIME": { + "summary": "Get the expiration Unix timestamp for a key in milliseconds", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "7.0.0", + "group": "generic" + }, + "PFADD": { + "summary": "Adds the specified elements to the specified HyperLogLog.", + "complexity": "O(1) to add every element.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "element", + "type": "string", + "optional": true, + "multiple": true + } + ], + "since": "2.8.9", + "group": "hyperloglog" + }, + "PFCOUNT": { + "summary": "Return the approximated cardinality of the set(s) observed by the HyperLogLog at key(s).", + "complexity": "O(1) with a very small average constant time when called with a single key. O(N) with N being the number of keys, and much bigger constant times, when called with multiple keys.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "2.8.9", + "group": "hyperloglog" + }, + "PFMERGE": { + "summary": "Merge N different HyperLogLogs into a single one.", + "complexity": "O(N) to merge N HyperLogLogs, but with high constant times.", + "arguments": [ + { + "name": "destkey", + "type": "key" + }, + { + "name": "sourcekey", + "type": "key", + "multiple": true + } + ], + "since": "2.8.9", + "group": "hyperloglog" + }, + "PING": { + "summary": "Ping the server", + "arguments": [ + { + "name": "message", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "connection" + }, + "PSETEX": { + "summary": "Set the value and expiration in milliseconds of a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "milliseconds", + "type": "integer" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "2.6.0", + "group": "string" + }, + "PSUBSCRIBE": { + "summary": "Listen for messages published to channels matching the given patterns", + "complexity": "O(N) where N is the number of patterns the client is already subscribed to.", + "arguments": [ + { + "name": [ + "pattern" + ], + "type": [ + "pattern" + ], + "multiple": true + } + ], + "since": "2.0.0", + "group": "pubsub" + }, + "PUBSUB": { + "summary": "Inspect the state of the Pub/Sub subsystem", + "complexity": "O(N) for the CHANNELS subcommand, where N is the number of active channels, and assuming constant time pattern matching (relatively short channels and patterns). O(N) for the NUMSUB subcommand, where N is the number of requested channels. O(1) for the NUMPAT subcommand.", + "arguments": [ + { + "name": "subcommand", + "type": "string" + }, + { + "name": "argument", + "type": "string", + "optional": true, + "multiple": true + } + ], + "since": "2.8.0", + "group": "pubsub" + }, + "PTTL": { + "summary": "Get the time to live for a key in milliseconds", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.6.0", + "group": "generic" + }, + "PUBLISH": { + "summary": "Post a message to a channel", + "complexity": "O(N+M) where N is the number of clients subscribed to the receiving channel and M is the total number of subscribed patterns (by any client).", + "arguments": [ + { + "name": "channel", + "type": "string" + }, + { + "name": "message", + "type": "string" + } + ], + "since": "2.0.0", + "group": "pubsub" + }, + "PUNSUBSCRIBE": { + "summary": "Stop listening for messages posted to channels matching the given patterns", + "complexity": "O(N+M) where N is the number of patterns the client is already subscribed and M is the number of total patterns subscribed in the system (by any client).", + "arguments": [ + { + "name": "pattern", + "type": "pattern", + "optional": true, + "multiple": true + } + ], + "since": "2.0.0", + "group": "pubsub" + }, + "QUIT": { + "summary": "Close the connection", + "since": "1.0.0", + "group": "connection" + }, + "RANDOMKEY": { + "summary": "Return a random key from the keyspace", + "complexity": "O(1)", + "since": "1.0.0", + "group": "generic" + }, + "READONLY": { + "summary": "Enables read queries for a connection to a cluster replica node", + "complexity": "O(1)", + "since": "3.0.0", + "group": "cluster" + }, + "READWRITE": { + "summary": "Disables read queries for a connection to a cluster replica node", + "complexity": "O(1)", + "since": "3.0.0", + "group": "cluster" + }, + "RENAME": { + "summary": "Rename a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "newkey", + "type": "key" + } + ], + "since": "1.0.0", + "group": "generic" + }, + "RENAMENX": { + "summary": "Rename a key, only if the new key does not exist", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "newkey", + "type": "key" + } + ], + "since": "1.0.0", + "group": "generic" + }, + "RESET": { + "summary": "Reset the connection", + "since": "6.2", + "group": "connection" + }, + "RESTORE": { + "summary": "Create a key using the provided serialized value, previously obtained using DUMP.", + "complexity": "O(1) to create the new key and additional O(N*M) to reconstruct the serialized value, where N is the number of Redis objects composing the value and M their average size. For small string values the time complexity is thus O(1)+O(1*M) where M is small, so simply O(1). However for sorted set values the complexity is O(N*M*log(N)) because inserting values into sorted sets is O(log(N)).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "ttl", + "type": "integer" + }, + { + "name": "serialized-value", + "type": "string" + }, + { + "name": "replace", + "type": "enum", + "enum": [ + "REPLACE" + ], + "optional": true + }, + { + "name": "absttl", + "type": "enum", + "enum": [ + "ABSTTL" + ], + "optional": true + }, + { + "command": "IDLETIME", + "name": "seconds", + "type": "integer", + "optional": true + }, + { + "command": "FREQ", + "name": "frequency", + "type": "integer", + "optional": true + } + ], + "since": "2.6.0", + "group": "generic" + }, + "ROLE": { + "summary": "Return the role of the instance in the context of replication", + "since": "2.8.12", + "group": "server" + }, + "RPOP": { + "summary": "Remove and get the last elements in a list", + "complexity": "O(N) where N is the number of elements returned", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "1.0.0", + "group": "list" + }, + "RPOPLPUSH": { + "summary": "Remove the last element in a list, prepend it to another list and return it", + "complexity": "O(1)", + "arguments": [ + { + "name": "source", + "type": "key" + }, + { + "name": "destination", + "type": "key" + } + ], + "since": "1.2.0", + "group": "list" + }, + "LMOVE": { + "summary": "Pop an element from a list, push it to another list and return it", + "complexity": "O(1)", + "arguments": [ + { + "name": "source", + "type": "key" + }, + { + "name": "destination", + "type": "key" + }, + { + "name": "wherefrom", + "type": "enum", + "enum": [ + "LEFT", + "RIGHT" + ] + }, + { + "name": "whereto", + "type": "enum", + "enum": [ + "LEFT", + "RIGHT" + ] + } + ], + "since": "6.2.0", + "group": "list" + }, + "RPUSH": { + "summary": "Append one or multiple elements to a list", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "element", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "list" + }, + "RPUSHX": { + "summary": "Append an element to a list, only if the list exists", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "element", + "type": "string", + "multiple": true + } + ], + "since": "2.2.0", + "group": "list" + }, + "SADD": { + "summary": "Add one or more members to a set", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "SAVE": { + "summary": "Synchronously save the dataset to disk", + "since": "1.0.0", + "group": "server" + }, + "SCARD": { + "summary": "Get the number of members in a set", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "set" + }, + "SCRIPT DEBUG": { + "summary": "Set the debug mode for executed scripts.", + "complexity": "O(1)", + "arguments": [ + { + "name": "mode", + "type": "enum", + "enum": [ + "YES", + "SYNC", + "NO" + ] + } + ], + "since": "3.2.0", + "group": "scripting" + }, + "SCRIPT EXISTS": { + "summary": "Check existence of scripts in the script cache.", + "complexity": "O(N) with N being the number of scripts to check (so checking a single script is an O(1) operation).", + "arguments": [ + { + "name": "sha1", + "type": "string", + "multiple": true + } + ], + "since": "2.6.0", + "group": "scripting" + }, + "SCRIPT FLUSH": { + "summary": "Remove all the scripts from the script cache.", + "arguments": [ + { + "name": "async", + "type": "enum", + "enum": [ + "ASYNC", + "SYNC" + ], + "optional": true + } + ], + "complexity": "O(N) with N being the number of scripts in cache", + "since": "2.6.0", + "group": "scripting" + }, + "SCRIPT KILL": { + "summary": "Kill the script currently in execution.", + "complexity": "O(1)", + "since": "2.6.0", + "group": "scripting" + }, + "SCRIPT LOAD": { + "summary": "Load the specified Lua script into the script cache.", + "complexity": "O(N) with N being the length in bytes of the script body.", + "arguments": [ + { + "name": "script", + "type": "string" + } + ], + "since": "2.6.0", + "group": "scripting" + }, + "SDIFF": { + "summary": "Subtract multiple sets", + "complexity": "O(N) where N is the total number of elements in all given sets.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "SDIFFSTORE": { + "summary": "Subtract multiple sets and store the resulting set in a key", + "complexity": "O(N) where N is the total number of elements in all given sets.", + "arguments": [ + { + "name": "destination", + "type": "key" + }, + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "SELECT": { + "summary": "Change the selected database for the current connection", + "arguments": [ + { + "name": "index", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "connection" + }, + "SET": { + "summary": "Set the string value of a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "value", + "type": "string" + }, + { + "name": "expiration", + "type": "enum", + "enum": [ + "EX seconds", + "PX milliseconds", + "EXAT timestamp", + "PXAT milliseconds-timestamp", + "KEEPTTL" + ], + "optional": true + }, + { + "name": "condition", + "type": "enum", + "enum": [ + "NX", + "XX" + ], + "optional": true + }, + { + "name": "get", + "type": "enum", + "enum": [ + "GET" + ], + "optional": true + } + ], + "since": "1.0.0", + "group": "string" + }, + "SETBIT": { + "summary": "Sets or clears the bit at offset in the string value stored at key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "offset", + "type": "integer" + }, + { + "name": "value", + "type": "integer" + } + ], + "since": "2.2.0", + "group": "bitmap" + }, + "SETEX": { + "summary": "Set the value and expiration of a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "seconds", + "type": "integer" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "2.0.0", + "group": "string" + }, + "SETNX": { + "summary": "Set the value of a key, only if the key does not exist", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "1.0.0", + "group": "string" + }, + "SETRANGE": { + "summary": "Overwrite part of a string at key starting at the specified offset", + "complexity": "O(1), not counting the time taken to copy the new string in place. Usually, this string is very small so the amortized complexity is O(1). Otherwise, complexity is O(M) with M being the length of the value argument.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "offset", + "type": "integer" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "2.2.0", + "group": "string" + }, + "SHUTDOWN": { + "summary": "Synchronously save the dataset to disk and then shut down the server", + "arguments": [ + { + "name": "save-mode", + "type": "enum", + "enum": [ + "NOSAVE", + "SAVE" + ], + "optional": true + } + ], + "since": "1.0.0", + "group": "server" + }, + "SINTER": { + "summary": "Intersect multiple sets", + "complexity": "O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "SINTERCARD": { + "summary": "Intersect multiple sets and return the cardinality of the result", + "complexity": "O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "7.0.0", + "group": "set" + }, + "SINTERSTORE": { + "summary": "Intersect multiple sets and store the resulting set in a key", + "complexity": "O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.", + "arguments": [ + { + "name": "destination", + "type": "key" + }, + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "SISMEMBER": { + "summary": "Determine if a given value is a member of a set", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string" + } + ], + "since": "1.0.0", + "group": "set" + }, + "SMISMEMBER": { + "summary": "Returns the membership associated with the given elements for a set", + "complexity": "O(N) where N is the number of elements being checked for membership", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string", + "multiple": true + } + ], + "since": "6.2.0", + "group": "set" + }, + "SLAVEOF": { + "summary": "Make the server a replica of another instance, or promote it as master. Deprecated starting with Redis 5. Use REPLICAOF instead.", + "arguments": [ + { + "name": "host", + "type": "string" + }, + { + "name": "port", + "type": "string" + } + ], + "since": "1.0.0", + "group": "server" + }, + "REPLICAOF": { + "summary": "Make the server a replica of another instance, or promote it as master.", + "arguments": [ + { + "name": "host", + "type": "string" + }, + { + "name": "port", + "type": "string" + } + ], + "since": "5.0.0", + "group": "server" + }, + "SLOWLOG": { + "summary": "Manages the Redis slow queries log", + "arguments": [ + { + "name": "subcommand", + "type": "string" + }, + { + "name": "argument", + "type": "string", + "optional": true + } + ], + "since": "2.2.12", + "group": "server" + }, + "SMEMBERS": { + "summary": "Get all the members in a set", + "complexity": "O(N) where N is the set cardinality.", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "set" + }, + "SMOVE": { + "summary": "Move a member from one set to another", + "complexity": "O(1)", + "arguments": [ + { + "name": "source", + "type": "key" + }, + { + "name": "destination", + "type": "key" + }, + { + "name": "member", + "type": "string" + } + ], + "since": "1.0.0", + "group": "set" + }, + "SORT": { + "summary": "Sort the elements in a list, set or sorted set", + "complexity": "O(N+M*log(M)) where N is the number of elements in the list or set to sort, and M the number of returned elements. When the elements are not sorted, complexity is O(N).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "command": "BY", + "name": "pattern", + "type": "pattern", + "optional": true + }, + { + "command": "LIMIT", + "name": [ + "offset", + "count" + ], + "type": [ + "integer", + "integer" + ], + "optional": true + }, + { + "command": "GET", + "name": "pattern", + "type": "string", + "optional": true, + "multiple": true + }, + { + "name": "order", + "type": "enum", + "enum": [ + "ASC", + "DESC" + ], + "optional": true + }, + { + "name": "sorting", + "type": "enum", + "enum": [ + "ALPHA" + ], + "optional": true + }, + { + "command": "STORE", + "name": "destination", + "type": "key", + "optional": true + } + ], + "since": "1.0.0", + "group": "generic" + }, + "SORT_RO": { + "summary": "Sort the elements in a list, set or sorted set. Read-only variant of SORT.", + "complexity": "O(N+M*log(M)) where N is the number of elements in the list or set to sort, and M the number of returned elements. When the elements are not sorted, complexity is O(N).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "command": "BY", + "name": "pattern", + "type": "pattern", + "optional": true + }, + { + "command": "LIMIT", + "name": [ + "offset", + "count" + ], + "type": [ + "integer", + "integer" + ], + "optional": true + }, + { + "command": "GET", + "name": "pattern", + "type": "string", + "optional": true, + "multiple": true + }, + { + "name": "order", + "type": "enum", + "enum": [ + "ASC", + "DESC" + ], + "optional": true + }, + { + "name": "sorting", + "type": "enum", + "enum": [ + "ALPHA" + ], + "optional": true + } + ], + "since": "7.0.0", + "group": "generic" + }, + "SPOP": { + "summary": "Remove and return one or multiple random members from a set", + "complexity": "Without the count argument O(1), otherwise O(N) where N is the value of the passed count.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "SRANDMEMBER": { + "summary": "Get one or multiple random members from a set", + "complexity": "Without the count argument O(1), otherwise O(N) where N is the absolute value of the passed count.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "SREM": { + "summary": "Remove one or more members from a set", + "complexity": "O(N) where N is the number of members to be removed.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "STRALGO": { + "summary": "Run algorithms (currently LCS) against strings", + "complexity": "For LCS O(strlen(s1)*strlen(s2))", + "arguments": [ + { + "name": "algorithm", + "type": "enum", + "enum": [ + "LCS" + ] + }, + { + "name": "algo-specific-argument", + "type": "string", + "multiple": true + } + ], + "since": "6.0.0", + "group": "string" + }, + "STRLEN": { + "summary": "Get the length of the value stored in a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.2.0", + "group": "string" + }, + "SUBSCRIBE": { + "summary": "Listen for messages published to the given channels", + "complexity": "O(N) where N is the number of channels to subscribe to.", + "arguments": [ + { + "name": "channel", + "type": "string", + "multiple": true + } + ], + "since": "2.0.0", + "group": "pubsub" + }, + "SUNION": { + "summary": "Add multiple sets", + "complexity": "O(N) where N is the total number of elements in all given sets.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "SUNIONSTORE": { + "summary": "Add multiple sets and store the resulting set in a key", + "complexity": "O(N) where N is the total number of elements in all given sets.", + "arguments": [ + { + "name": "destination", + "type": "key" + }, + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "1.0.0", + "group": "set" + }, + "SWAPDB": { + "summary": "Swaps two Redis databases", + "complexity": "O(N) where N is the count of clients watching or blocking on keys from both databases.", + "arguments": [ + { + "name": "index1", + "type": "integer" + }, + { + "name": "index2", + "type": "integer" + } + ], + "since": "4.0.0", + "group": "server" + }, + "SYNC": { + "summary": "Internal command used for replication", + "since": "1.0.0", + "group": "server" + }, + "PSYNC": { + "summary": "Internal command used for replication", + "arguments": [ + { + "name": "replicationid", + "type": "integer" + }, + { + "name": "offset", + "type": "integer" + } + ], + "since": "2.8.0", + "group": "server" + }, + "TIME": { + "summary": "Return the current server time", + "complexity": "O(1)", + "since": "2.6.0", + "group": "server" + }, + "TOUCH": { + "summary": "Alters the last access time of a key(s). Returns the number of existing keys specified.", + "complexity": "O(N) where N is the number of keys that will be touched.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "3.2.1", + "group": "generic" + }, + "TTL": { + "summary": "Get the time to live for a key in seconds", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "generic" + }, + "TYPE": { + "summary": "Determine the type stored at key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "generic" + }, + "UNSUBSCRIBE": { + "summary": "Stop listening for messages posted to the given channels", + "complexity": "O(N) where N is the number of clients already subscribed to a channel.", + "arguments": [ + { + "name": "channel", + "type": "string", + "optional": true, + "multiple": true + } + ], + "since": "2.0.0", + "group": "pubsub" + }, + "UNLINK": { + "summary": "Delete a key asynchronously in another thread. Otherwise it is just as DEL, but non blocking.", + "complexity": "O(1) for each key removed regardless of its size. Then the command does O(N) work in a different thread in order to reclaim memory, where N is the number of allocations the deleted objects where composed of.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "4.0.0", + "group": "generic" + }, + "UNWATCH": { + "summary": "Forget about all watched keys", + "complexity": "O(1)", + "since": "2.2.0", + "group": "transactions" + }, + "WAIT": { + "summary": "Wait for the synchronous replication of all the write commands sent in the context of the current connection", + "complexity": "O(1)", + "arguments": [ + { + "name": "numreplicas", + "type": "integer" + }, + { + "name": "timeout", + "type": "integer" + } + ], + "since": "3.0.0", + "group": "generic" + }, + "WATCH": { + "summary": "Watch the given keys to determine execution of the MULTI/EXEC block", + "complexity": "O(1) for every key.", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "2.2.0", + "group": "transactions" + }, + "ZADD": { + "summary": "Add one or more members to a sorted set, or update its score if it already exists", + "complexity": "O(log(N)) for each item added, where N is the number of elements in the sorted set.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "condition", + "type": "enum", + "enum": [ + "NX", + "XX" + ], + "optional": true + }, + { + "name": "comparison", + "type": "enum", + "enum": [ + "GT", + "LT" + ], + "optional": true + }, + { + "name": "change", + "type": "enum", + "enum": [ + "CH" + ], + "optional": true + }, + { + "name": "increment", + "type": "enum", + "enum": [ + "INCR" + ], + "optional": true + }, + { + "name": [ + "score", + "member" + ], + "type": [ + "double", + "string" + ], + "multiple": true + } + ], + "since": "1.2.0", + "group": "sorted_set" + }, + "ZCARD": { + "summary": "Get the number of members in a sorted set", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.2.0", + "group": "sorted_set" + }, + "ZCOUNT": { + "summary": "Count the members in a sorted set with scores within the given values", + "complexity": "O(log(N)) with N being the number of elements in the sorted set.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "min", + "type": "double" + }, + { + "name": "max", + "type": "double" + } + ], + "since": "2.0.0", + "group": "sorted_set" + }, + "ZDIFF": { + "summary": "Subtract multiple sorted sets", + "complexity": "O(L + (N-K)log(N)) worst case where L is the total number of elements in all the sets, N is the size of the first set, and K is the size of the result set.", + "arguments": [ + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "withscores", + "type": "enum", + "enum": [ + "WITHSCORES" + ], + "optional": true + } + ], + "since": "6.2.0", + "group": "sorted_set" + }, + "ZDIFFSTORE": { + "summary": "Subtract multiple sorted sets and store the resulting sorted set in a new key", + "complexity": "O(L + (N-K)log(N)) worst case where L is the total number of elements in all the sets, N is the size of the first set, and K is the size of the result set.", + "arguments": [ + { + "name": "destination", + "type": "key" + }, + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "6.2.0", + "group": "sorted_set" + }, + "ZINCRBY": { + "summary": "Increment the score of a member in a sorted set", + "complexity": "O(log(N)) where N is the number of elements in the sorted set.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "increment", + "type": "integer" + }, + { + "name": "member", + "type": "string" + } + ], + "since": "1.2.0", + "group": "sorted_set" + }, + "ZINTER": { + "summary": "Intersect multiple sorted sets", + "complexity": "O(N*K)+O(M*log(M)) worst case with N being the smallest input sorted set, K being the number of input sorted sets and M being the number of elements in the resulting sorted set.", + "arguments": [ + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "command": "WEIGHTS", + "name": "weight", + "type": "integer", + "variadic": true, + "optional": true + }, + { + "command": "AGGREGATE", + "name": "aggregate", + "type": "enum", + "enum": [ + "SUM", + "MIN", + "MAX" + ], + "optional": true + }, + { + "name": "withscores", + "type": "enum", + "enum": [ + "WITHSCORES" + ], + "optional": true + } + ], + "since": "6.2.0", + "group": "sorted_set" + }, + "ZINTERCARD": { + "summary": "Intersect multiple sorted sets and return the cardinality of the result", + "complexity": "O(N*K) worst case with N being the smallest input sorted set, K being the number of input sorted sets.", + "arguments": [ + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "multiple": true + } + ], + "since": "7.0.0", + "group": "sorted_set" + }, + "ZINTERSTORE": { + "summary": "Intersect multiple sorted sets and store the resulting sorted set in a new key", + "complexity": "O(N*K)+O(M*log(M)) worst case with N being the smallest input sorted set, K being the number of input sorted sets and M being the number of elements in the resulting sorted set.", + "arguments": [ + { + "name": "destination", + "type": "key" + }, + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "command": "WEIGHTS", + "name": "weight", + "type": "integer", + "variadic": true, + "optional": true + }, + { + "command": "AGGREGATE", + "name": "aggregate", + "type": "enum", + "enum": [ + "SUM", + "MIN", + "MAX" + ], + "optional": true + } + ], + "since": "2.0.0", + "group": "sorted_set" + }, + "ZLEXCOUNT": { + "summary": "Count the number of members in a sorted set between a given lexicographical range", + "complexity": "O(log(N)) with N being the number of elements in the sorted set.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "min", + "type": "string" + }, + { + "name": "max", + "type": "string" + } + ], + "since": "2.8.9", + "group": "sorted_set" + }, + "ZPOPMAX": { + "summary": "Remove and return members with the highest scores in a sorted set", + "complexity": "O(log(N)*M) with N being the number of elements in the sorted set, and M being the number of elements popped.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "5.0.0", + "group": "sorted_set" + }, + "ZPOPMIN": { + "summary": "Remove and return members with the lowest scores in a sorted set", + "complexity": "O(log(N)*M) with N being the number of elements in the sorted set, and M being the number of elements popped.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "5.0.0", + "group": "sorted_set" + }, + "ZRANDMEMBER": { + "summary": "Get one or multiple random elements from a sorted set", + "complexity": "O(N) where N is the number of elements returned", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "options", + "type": "block", + "block": [ + { + "name": "count", + "type": "integer" + }, + { + "name": "withscores", + "type": "enum", + "enum": [ + "WITHSCORES" + ], + "optional": true + } + ], + "optional": true + } + ], + "since": "6.2.0", + "group": "sorted_set" + }, + "ZRANGESTORE": { + "summary": "Store a range of members from sorted set into another key", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements stored into the destination key.", + "arguments": [ + { + "name": "dst", + "type": "key" + }, + { + "name": "src", + "type": "key" + }, + { + "name": "min", + "type": "string" + }, + { + "name": "max", + "type": "string" + }, + { + "name": "sortby", + "type": "enum", + "enum": [ + "BYSCORE", + "BYLEX" + ], + "optional": true + }, + { + "name": "rev", + "type": "enum", + "enum": [ + "REV" + ], + "optional": true + }, + { + "command": "LIMIT", + "name": [ + "offset", + "count" + ], + "type": [ + "integer", + "integer" + ], + "optional": true + } + ], + "since": "6.2.0", + "group": "sorted_set" + }, + "ZRANGE": { + "summary": "Return a range of members in a sorted set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements returned.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "min", + "type": "string" + }, + { + "name": "max", + "type": "string" + }, + { + "name": "sortby", + "type": "enum", + "enum": [ + "BYSCORE", + "BYLEX" + ], + "optional": true + }, + { + "name": "rev", + "type": "enum", + "enum": [ + "REV" + ], + "optional": true + }, + { + "command": "LIMIT", + "name": [ + "offset", + "count" + ], + "type": [ + "integer", + "integer" + ], + "optional": true + }, + { + "name": "withscores", + "type": "enum", + "enum": [ + "WITHSCORES" + ], + "optional": true + } + ], + "since": "1.2.0", + "group": "sorted_set" + }, + "ZRANGEBYLEX": { + "summary": "Return a range of members in a sorted set, by lexicographical range", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements being returned. If M is constant (e.g. always asking for the first 10 elements with LIMIT), you can consider it O(log(N)).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "min", + "type": "string" + }, + { + "name": "max", + "type": "string" + }, + { + "command": "LIMIT", + "name": [ + "offset", + "count" + ], + "type": [ + "integer", + "integer" + ], + "optional": true + } + ], + "since": "2.8.9", + "group": "sorted_set" + }, + "ZREVRANGEBYLEX": { + "summary": "Return a range of members in a sorted set, by lexicographical range, ordered from higher to lower strings.", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements being returned. If M is constant (e.g. always asking for the first 10 elements with LIMIT), you can consider it O(log(N)).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "max", + "type": "string" + }, + { + "name": "min", + "type": "string" + }, + { + "command": "LIMIT", + "name": [ + "offset", + "count" + ], + "type": [ + "integer", + "integer" + ], + "optional": true + } + ], + "since": "2.8.9", + "group": "sorted_set" + }, + "ZRANGEBYSCORE": { + "summary": "Return a range of members in a sorted set, by score", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements being returned. If M is constant (e.g. always asking for the first 10 elements with LIMIT), you can consider it O(log(N)).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "min", + "type": "double" + }, + { + "name": "max", + "type": "double" + }, + { + "name": "withscores", + "type": "enum", + "enum": [ + "WITHSCORES" + ], + "optional": true + }, + { + "command": "LIMIT", + "name": [ + "offset", + "count" + ], + "type": [ + "integer", + "integer" + ], + "optional": true + } + ], + "since": "1.0.5", + "group": "sorted_set" + }, + "ZRANK": { + "summary": "Determine the index of a member in a sorted set", + "complexity": "O(log(N))", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string" + } + ], + "since": "2.0.0", + "group": "sorted_set" + }, + "ZREM": { + "summary": "Remove one or more members from a sorted set", + "complexity": "O(M*log(N)) with N being the number of elements in the sorted set and M the number of elements to be removed.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string", + "multiple": true + } + ], + "since": "1.2.0", + "group": "sorted_set" + }, + "ZREMRANGEBYLEX": { + "summary": "Remove all members in a sorted set between the given lexicographical range", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements removed by the operation.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "min", + "type": "string" + }, + { + "name": "max", + "type": "string" + } + ], + "since": "2.8.9", + "group": "sorted_set" + }, + "ZREMRANGEBYRANK": { + "summary": "Remove all members in a sorted set within the given indexes", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements removed by the operation.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "start", + "type": "integer" + }, + { + "name": "stop", + "type": "integer" + } + ], + "since": "2.0.0", + "group": "sorted_set" + }, + "ZREMRANGEBYSCORE": { + "summary": "Remove all members in a sorted set within the given scores", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements removed by the operation.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "min", + "type": "double" + }, + { + "name": "max", + "type": "double" + } + ], + "since": "1.2.0", + "group": "sorted_set" + }, + "ZREVRANGE": { + "summary": "Return a range of members in a sorted set, by index, with scores ordered from high to low", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements returned.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "start", + "type": "integer" + }, + { + "name": "stop", + "type": "integer" + }, + { + "name": "withscores", + "type": "enum", + "enum": [ + "WITHSCORES" + ], + "optional": true + } + ], + "since": "1.2.0", + "group": "sorted_set" + }, + "ZREVRANGEBYSCORE": { + "summary": "Return a range of members in a sorted set, by score, with scores ordered from high to low", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements being returned. If M is constant (e.g. always asking for the first 10 elements with LIMIT), you can consider it O(log(N)).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "max", + "type": "double" + }, + { + "name": "min", + "type": "double" + }, + { + "name": "withscores", + "type": "enum", + "enum": [ + "WITHSCORES" + ], + "optional": true + }, + { + "command": "LIMIT", + "name": [ + "offset", + "count" + ], + "type": [ + "integer", + "integer" + ], + "optional": true + } + ], + "since": "2.2.0", + "group": "sorted_set" + }, + "ZREVRANK": { + "summary": "Determine the index of a member in a sorted set, with scores ordered from high to low", + "complexity": "O(log(N))", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string" + } + ], + "since": "2.0.0", + "group": "sorted_set" + }, + "ZSCORE": { + "summary": "Get the score associated with the given member in a sorted set", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string" + } + ], + "since": "1.2.0", + "group": "sorted_set" + }, + "ZUNION": { + "summary": "Add multiple sorted sets", + "complexity": "O(N)+O(M*log(M)) with N being the sum of the sizes of the input sorted sets, and M being the number of elements in the resulting sorted set.", + "arguments": [ + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "command": "WEIGHTS", + "name": "weight", + "type": "integer", + "variadic": true, + "optional": true + }, + { + "command": "AGGREGATE", + "name": "aggregate", + "type": "enum", + "enum": [ + "SUM", + "MIN", + "MAX" + ], + "optional": true + }, + { + "name": "withscores", + "type": "enum", + "enum": [ + "WITHSCORES" + ], + "optional": true + } + ], + "since": "6.2.0", + "group": "sorted_set" + }, + "ZMSCORE": { + "summary": "Get the score associated with the given members in a sorted set", + "complexity": "O(N) where N is the number of members being requested.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "member", + "type": "string", + "multiple": true + } + ], + "since": "6.2.0", + "group": "sorted_set" + }, + "ZUNIONSTORE": { + "summary": "Add multiple sorted sets and store the resulting sorted set in a new key", + "complexity": "O(N)+O(M log(M)) with N being the sum of the sizes of the input sorted sets, and M being the number of elements in the resulting sorted set.", + "arguments": [ + { + "name": "destination", + "type": "key" + }, + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "command": "WEIGHTS", + "name": "weight", + "type": "integer", + "variadic": true, + "optional": true + }, + { + "command": "AGGREGATE", + "name": "aggregate", + "type": "enum", + "enum": [ + "SUM", + "MIN", + "MAX" + ], + "optional": true + } + ], + "since": "2.0.0", + "group": "sorted_set" + }, + "SCAN": { + "summary": "Incrementally iterate the keys space", + "complexity": "O(1) for every call. O(N) for a complete iteration, including enough command calls for the cursor to return back to 0. N is the number of elements inside the collection.", + "arguments": [ + { + "name": "cursor", + "type": "integer" + }, + { + "command": "MATCH", + "name": "pattern", + "type": "pattern", + "optional": true + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + }, + { + "command": "TYPE", + "name": "type", + "type": "string", + "optional": true + } + ], + "since": "2.8.0", + "group": "generic" + }, + "SSCAN": { + "summary": "Incrementally iterate Set elements", + "complexity": "O(1) for every call. O(N) for a complete iteration, including enough command calls for the cursor to return back to 0. N is the number of elements inside the collection..", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "cursor", + "type": "integer" + }, + { + "command": "MATCH", + "name": "pattern", + "type": "pattern", + "optional": true + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "2.8.0", + "group": "set" + }, + "HSCAN": { + "summary": "Incrementally iterate hash fields and associated values", + "complexity": "O(1) for every call. O(N) for a complete iteration, including enough command calls for the cursor to return back to 0. N is the number of elements inside the collection..", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "cursor", + "type": "integer" + }, + { + "command": "MATCH", + "name": "pattern", + "type": "pattern", + "optional": true + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "2.8.0", + "group": "hash" + }, + "ZSCAN": { + "summary": "Incrementally iterate sorted sets elements and associated scores", + "complexity": "O(1) for every call. O(N) for a complete iteration, including enough command calls for the cursor to return back to 0. N is the number of elements inside the collection..", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "cursor", + "type": "integer" + }, + { + "command": "MATCH", + "name": "pattern", + "type": "pattern", + "optional": true + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "2.8.0", + "group": "sorted_set" + }, + "XINFO": { + "summary": "Get information on streams and consumer groups", + "complexity": "O(N) with N being the number of returned items for the subcommands CONSUMERS and GROUPS. The STREAM subcommand is O(log N) with N being the number of items in the stream.", + "arguments": [ + { + "command": "CONSUMERS", + "name": [ + "key", + "groupname" + ], + "type": [ + "key", + "string" + ], + "optional": true + }, + { + "command": "GROUPS", + "name": "key", + "type": "key", + "optional": true + }, + { + "command": "STREAM", + "name": "key", + "type": "key", + "optional": true + }, + { + "name": "help", + "type": "enum", + "enum": [ + "HELP" + ], + "optional": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XADD": { + "summary": "Appends a new entry to a stream", + "complexity": "O(1) when adding a new entry, O(N) when trimming where N being the number of entires evicted.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "command": "NOMKSTREAM", + "optional": true + }, + { + "name": "trim", + "type": "block", + "optional": true, + "block": [ + { + "name": "strategy", + "type": "enum", + "enum": [ + "MAXLEN", + "MINID" + ] + }, + { + "name": "operator", + "type": "enum", + "enum": [ + "=", + "~" + ], + "optional": true + }, + { + "name": "threshold", + "type": "string" + }, + { + "command": "LIMIT", + "name": "count", + "type": "integer", + "optional": true + } + ] + }, + { + "type": "enum", + "enum": [ + "*", + "ID" + ] + }, + { + "name": [ + "field", + "value" + ], + "type": [ + "string", + "string" + ], + "multiple": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XTRIM": { + "summary": "Trims the stream to (approximately if '~' is passed) a certain size", + "complexity": "O(N), with N being the number of evicted entries. Constant times are very small however, since entries are organized in macro nodes containing multiple entries that can be released with a single deallocation.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "trim", + "type": "block", + "block": [ + { + "name": "strategy", + "type": "enum", + "enum": [ + "MAXLEN", + "MINID" + ] + }, + { + "name": "operator", + "type": "enum", + "enum": [ + "=", + "~" + ], + "optional": true + }, + { + "name": "threshold", + "type": "string" + }, + { + "command": "LIMIT", + "name": "count", + "type": "integer", + "optional": true + } + ] + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XDEL": { + "summary": "Removes the specified entries from the stream. Returns the number of items actually deleted, that may be different from the number of IDs passed in case certain IDs do not exist.", + "complexity": "O(1) for each single item to delete in the stream, regardless of the stream size.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "ID", + "type": "string", + "multiple": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XRANGE": { + "summary": "Return a range of elements in a stream, with IDs matching the specified IDs interval", + "complexity": "O(N) with N being the number of elements being returned. If N is constant (e.g. always asking for the first 10 elements with COUNT), you can consider it O(1).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "start", + "type": "string" + }, + { + "name": "end", + "type": "string" + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XREVRANGE": { + "summary": "Return a range of elements in a stream, with IDs matching the specified IDs interval, in reverse order (from greater to smaller IDs) compared to XRANGE", + "complexity": "O(N) with N being the number of elements returned. If N is constant (e.g. always asking for the first 10 elements with COUNT), you can consider it O(1).", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "end", + "type": "string" + }, + { + "name": "start", + "type": "string" + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XLEN": { + "summary": "Return the number of entries in a stream", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XREAD": { + "summary": "Return never seen elements in multiple streams, with IDs greater than the ones reported by the caller for each stream. Can block.", + "complexity": "For each stream mentioned: O(N) with N being the number of elements being returned, it means that XREAD-ing with a fixed COUNT is O(1). Note that when the BLOCK option is used, XADD will pay O(M) time in order to serve the M clients blocked on the stream getting new data.", + "arguments": [ + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + }, + { + "command": "BLOCK", + "name": "milliseconds", + "type": "integer", + "optional": true + }, + { + "name": "streams", + "type": "enum", + "enum": [ + "STREAMS" + ] + }, + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "ID", + "type": "string", + "multiple": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XGROUP": { + "summary": "Create, destroy, and manage consumer groups.", + "complexity": "O(1) for all the subcommands, with the exception of the DESTROY subcommand which takes an additional O(M) time in order to delete the M entries inside the consumer group pending entries list (PEL).", + "arguments": [ + { + "name": "create", + "type": "block", + "block": [ + { + "command": "CREATE", + "name": [ + "key", + "groupname" + ], + "type": [ + "key", + "string" + ] + }, + { + "name": "id", + "type": "enum", + "enum": [ + "ID", + "$" + ] + }, + { + "command": "MKSTREAM", + "optional": true + } + ], + "optional": true + }, + { + "name": "setid", + "type": "block", + "block": [ + { + "command": "SETID", + "name": [ + "key", + "groupname" + ], + "type": [ + "key", + "string" + ] + }, + { + "name": "id", + "type": "enum", + "enum": [ + "ID", + "$" + ] + } + ], + "optional": true + }, + { + "command": "DESTROY", + "name": [ + "key", + "groupname" + ], + "type": [ + "key", + "string" + ], + "optional": true + }, + { + "command": "CREATECONSUMER", + "name": [ + "key", + "groupname", + "consumername" + ], + "type": [ + "key", + "string", + "string" + ], + "optional": true + }, + { + "command": "DELCONSUMER", + "name": [ + "key", + "groupname", + "consumername" + ], + "type": [ + "key", + "string", + "string" + ], + "optional": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XREADGROUP": { + "summary": "Return new entries from a stream using a consumer group, or access the history of the pending entries for a given consumer. Can block.", + "complexity": "For each stream mentioned: O(M) with M being the number of elements returned. If M is constant (e.g. always asking for the first 10 elements with COUNT), you can consider it O(1). On the other side when XREADGROUP blocks, XADD will pay the O(N) time in order to serve the N clients blocked on the stream getting new data.", + "arguments": [ + { + "command": "GROUP", + "name": [ + "group", + "consumer" + ], + "type": [ + "string", + "string" + ] + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + }, + { + "command": "BLOCK", + "name": "milliseconds", + "type": "integer", + "optional": true + }, + { + "name": "noack", + "type": "enum", + "enum": [ + "NOACK" + ], + "optional": true + }, + { + "name": "streams", + "type": "enum", + "enum": [ + "STREAMS" + ] + }, + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "ID", + "type": "string", + "multiple": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XACK": { + "summary": "Marks a pending message as correctly processed, effectively removing it from the pending entries list of the consumer group. Return value of the command is the number of messages successfully acknowledged, that is, the IDs we were actually able to resolve in the PEL.", + "complexity": "O(1) for each message ID processed.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "group", + "type": "string" + }, + { + "name": "ID", + "type": "string", + "multiple": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XCLAIM": { + "summary": "Changes (or acquires) ownership of a message in a consumer group, as if the message was delivered to the specified consumer.", + "complexity": "O(log N) with N being the number of messages in the PEL of the consumer group.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "group", + "type": "string" + }, + { + "name": "consumer", + "type": "string" + }, + { + "name": "min-idle-time", + "type": "string" + }, + { + "name": "ID", + "type": "string", + "multiple": true + }, + { + "command": "IDLE", + "name": "ms", + "type": "integer", + "optional": true + }, + { + "command": "TIME", + "name": "ms-unix-time", + "type": "integer", + "optional": true + }, + { + "command": "RETRYCOUNT", + "name": "count", + "type": "integer", + "optional": true + }, + { + "name": "force", + "enum": [ + "FORCE" + ], + "optional": true + }, + { + "name": "justid", + "enum": [ + "JUSTID" + ], + "optional": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "XAUTOCLAIM": { + "summary": "Changes (or acquires) ownership of messages in a consumer group, as if the messages were delivered to the specified consumer.", + "complexity": "O(1) if COUNT is small.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "group", + "type": "string" + }, + { + "name": "consumer", + "type": "string" + }, + { + "name": "min-idle-time", + "type": "string" + }, + { + "name": "start", + "type": "string" + }, + { + "command": "COUNT", + "name": "count", + "type": "integer", + "optional": true + }, + { + "name": "justid", + "enum": [ + "JUSTID" + ], + "optional": true + } + ], + "since": "6.2.0", + "group": "stream" + }, + "XPENDING": { + "summary": "Return information and entries from a stream consumer group pending entries list, that are messages fetched but never acknowledged.", + "complexity": "O(N) with N being the number of elements returned, so asking for a small fixed number of entries per call is O(1). O(M), where M is the total number of entries scanned when used with the IDLE filter. When the command returns just the summary and the list of consumers is small, it runs in O(1) time; otherwise, an additional O(N) time for iterating every consumer.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "group", + "type": "string" + }, + { + "type": "block", + "name": "filters", + "block": [ + { + "command": "IDLE", + "name": "min-idle-time", + "type": "integer", + "optional": true + }, + { + "name": "start", + "type": "string" + }, + { + "name": "end", + "type": "string" + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "consumer", + "type": "string", + "optional": true + } + ], + "optional": true + } + ], + "since": "5.0.0", + "group": "stream" + }, + "LATENCY DOCTOR": { + "summary": "Return a human readable latency analysis report.", + "since": "2.8.13", + "group": "server" + }, + "LATENCY GRAPH": { + "summary": "Return a latency graph for the event.", + "arguments": [ + { + "name": "event", + "type": "string" + } + ], + "since": "2.8.13", + "group": "server" + }, + "LATENCY HISTORY": { + "summary": "Return timestamp-latency samples for the event.", + "arguments": [ + { + "name": "event", + "type": "string" + } + ], + "since": "2.8.13", + "group": "server" + }, + "LATENCY LATEST": { + "summary": "Return the latest latency samples for all events.", + "since": "2.8.13", + "group": "server" + }, + "LATENCY RESET": { + "summary": "Reset latency data for one or more events.", + "arguments": [ + { + "name": "event", + "type": "string", + "optional": true, + "multiple": true + } + ], + "since": "2.8.13", + "group": "server" + }, + "LATENCY HELP": { + "summary": "Show helpful text about the different subcommands.", + "since": "2.8.13", + "group": "server" + } +} diff --git a/redisinsight/api/src/constants/commands/redijson.json b/redisinsight/api/src/constants/commands/redijson.json new file mode 100644 index 0000000000..6d3d8466cc --- /dev/null +++ b/redisinsight/api/src/constants/commands/redijson.json @@ -0,0 +1,59 @@ +{ + "JSON.DEL": { + "summary": "Deletes a value", + "complexity": "O(N), where N is the size of the deleted value", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "json path string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.GET": { + "summary": "Gets the value at one or more paths in JSON serialized form", + "complexity": "O(N), where N is the size of the value", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "indent", + "type": "string", + "optional": true + }, + { + "name": "newline", + "type": "string", + "optional": true + }, + { + "name": "space", + "type": "string", + "optional": true + }, + { + "name": "escape", + "type": "enum", + "enum": [ + "NOESCAPE" + ], + "optional": true + }, + { + "name": "paths", + "type": "json path string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + } +} diff --git a/redisinsight/api/src/constants/commands/redisai.json b/redisinsight/api/src/constants/commands/redisai.json new file mode 100644 index 0000000000..8eb9eeafc7 --- /dev/null +++ b/redisinsight/api/src/constants/commands/redisai.json @@ -0,0 +1,420 @@ +{ + "AI.TENSORSET": + { + "summary": "stores a tensor as the value of a key.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "type", + "type": "enum", + "enum": [ + "FLOAT" , "DOUBLE" , "INT8" , "INT16" , "INT32" , "INT64" , "UINT8", "UINT16", "STRING", "BOOL" + ] + }, + { + "name": "shape", + "type": "integer", + "multiple": true + }, + { + "name": "blob", + "command": "BLOB", + "type": "string", + "optional": true + }, + { + "name": "value", + "command": "VALUES", + "type": "string", + "multiple": true, + "optional": true + } + + ], + "since": "1.2.5", + "group": "tensor" + }, + "AI.TENSORGET": + { + "summary": "returns a tensor stored as key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "meta", + "type": "enum", + "enum": [ + "META" + ] + }, + { + "name": "format", + "type": "enum", + "enum": [ + "BLOB", "VALUES" + ], + "optional": true + } + + ], + "since": "1.2.5", + "group": "tensor" + }, + "AI.MODELSETORE": + { + "summary": "stores a model as the value of a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "backend", + "type": "enum", + "enum":["TF", "TORCH", "ONNX"] + }, + { + "name": "device", + "type": "enum", + "enum":["CPU", "GPU"] + }, + { + "name": "tag", + "command": "TAG", + "type": "string", + "optional": true + }, + { + "name": "batchsize", + "command": "BATCHSIZE ", + "type": "integer", + "optional": true + }, + { + "name": "minbatchsize", + "command": "BATCHSIZE ", + "type": "integer", + "optional": true + }, + { + "name": "minbatchtimeout", + "command": "MINBATCHTIMEOUT ", + "type": "integer", + "optional": true + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "input_count", + "type": "integer", + "command":"INPUTS" + }, + { + "name": "input", + "type": "string", + "multiple": true + } + + ] + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "output_count", + "type": "integer", + "command":"OUTPUTS" + }, + { + "name": "output", + "type": "string", + "multiple": true + } + + ] + }, + { + "name": "blob", + "command": "BLOB", + "type": "string", + "optional": true + } + + ], + "since": "1.2.5", + "group": "model" + }, + "AI.MODELGET": { + "summary": "returns a model's metadata and blob stored as a key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "meta", + "type": "enum", + "enum": [ + "META" + ], + "optional": true + }, + { + "name": "blob", + "type": "enum", + "enum": [ + "BLOB" + ], + "optional": true + } + ], + "since": "1.2.5", + "group": "model" + }, + "AI.MODELDEL": + { + "summary": "deletes a model stored as a key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.2.5", + "group": "model" + }, + "AI.MODELEXECUTE": + { + "summary": "runs a model stored as a key's value using its specified backend and device. It accepts one or more input tensors and store output tensors.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "type": "block", + "block": [ + { + "name": "input_count", + "type": "integer", + "command":"INPUTS" + }, + { + "name": "input", + "type": "string", + "multiple": true + } + + ] + }, + { + "type": "block", + "block": [ + { + "name": "output_count", + "type": "integer", + "command":"OUTPUTS" + }, + { + "name": "output", + "type": "string", + "multiple": true + } + + ] + }, + { + "name": "timeout", + "command": "TIMEOUT", + "type": "integer", + "optional": true + } + ], + "since": "1.2.5", + "group": "inference" + }, + "AI.SCRIPTSTORE": { + "summary": "stores a TorchScript as the value of a key.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "device", + "type": "enum", + "enum":["CPU", "GPU"] + }, + { + "name": "tag", + "command": "TAG", + "type": "string", + "optional": true + }, + { + "type": "block", + "block": [ + { + "name": "entry_point_count", + "type": "integer", + "command":"ENTRY_POINTS" + }, + { + "name": "entry_point", + "type": "string", + "multiple": true + } + + ] + } + ], + "since": "1.2.5", + "group": "script" + }, + "AI.SCRIPTGET": { + "summary": "returns the TorchScript stored as a key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "meta", + "type": "enum", + "enum": [ + "META" + ], + "optional": true + }, + { + "name": "source", + "type": "enum", + "enum": [ + "SOURCE" + ], + "optional": true + } + ], + "since": "1.2.5", + "group": "script" + }, + "AI.SCRIPTDEL": { + "summary": "deletes a script stored as a key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.2.5", + "group": "script" + }, + "AI.SCRIPTEXECUTE": + { + "summary": "command runs a script stored as a key's value on its specified device.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "function", + "type": "string" + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "key_count", + "type": "integer", + "command":"KEYS" + }, + { + "name": "key", + "type": "string", + "multiple": true + } + + ] + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "input_count", + "type": "integer", + "command":"INPUTS" + }, + { + "name": "input", + "type": "string", + "multiple": true + } + + ] + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "arg_count", + "type": "integer", + "command":"ARGS" + }, + { + "name": "arg", + "type": "string", + "multiple": true + } + + ] + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "output_count", + "type": "integer", + "command":"OUTPUTS" + }, + { + "name": "output", + "type": "string", + "multiple": true + } + + ] + }, + { + "name": "timeout", + "command": "TIMEOUT", + "type": "integer", + "optional": true + } + ], + "since": "1.2.5", + "group": "inference" + } +} diff --git a/redisinsight/api/src/constants/commands/redisearch.json b/redisinsight/api/src/constants/commands/redisearch.json new file mode 100644 index 0000000000..9f0c53e26c --- /dev/null +++ b/redisinsight/api/src/constants/commands/redisearch.json @@ -0,0 +1,34 @@ +{ + "FT.CREATE": { + "summary": "Creates an index with the given spec", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "key" + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.DROPINDEX": { + "summary": "Deletes the index", + "complexity": "O(N)", + "arguments": [ + { + "name": "index", + "type": "key" + }, + { + "name": "deletedocs", + "type": "enum", + "enum": [ + "DD" + ], + "optional": true + } + ], + "since": "2.0.0", + "group": "search" + } +} diff --git a/redisinsight/api/src/constants/commands/redisgraph.json b/redisinsight/api/src/constants/commands/redisgraph.json new file mode 100644 index 0000000000..d684adce66 --- /dev/null +++ b/redisinsight/api/src/constants/commands/redisgraph.json @@ -0,0 +1,32 @@ +{ + "GRAPH.QUERY": { + "summary": "Queries the graph", + "arguments": [ + { + "name": "graph", + "type": "key" + }, + { + "name": "query", + "type": "string" + } + ], + "since": "1.0.0", + "group": "graph" + }, + "GRAPH.EXPLAIN": { + "summary": "Produce execution plan for query", + "arguments": [ + { + "name": "graph", + "type": "key" + }, + { + "name": "query", + "type": "string" + } + ], + "since": "2.0.0", + "group": "graph" + } +} diff --git a/redisinsight/api/src/constants/commands/redistimeseries.json b/redisinsight/api/src/constants/commands/redistimeseries.json new file mode 100644 index 0000000000..cfe37dc8ae --- /dev/null +++ b/redisinsight/api/src/constants/commands/redistimeseries.json @@ -0,0 +1,127 @@ +{ + "TS.CREATE": { + "summary": "Create a new time-series", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "type": "integer", + "command": "RETENTION", + "name": "retentionTime", + "optional": true + }, + { + "type": "enum", + "command": "ENCODING", + "enum": [ + "UNCOMPRESSED", + "COMPRESSED" + ], + "optional": true + }, + { + "type": "integer", + "command": "CHUNK_SIZE", + "name": "size", + "optional": true + }, + { + "type": "enum", + "command": "DUPLICATE_POLICY", + "name": "policy", + "enum": [ + "BLOCK", + "FIRST", + "LAST", + "MIN", + "MAX", + "SUM" + ], + "optional": true + }, + { + "command": "LABELS", + "name": [ + "label", + "value" + ], + "type": [ + "string", + "string" + ], + "multiple": true, + "optional": true + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.ADD": { + "summary": "Append a new sample to the series. If the series has not been created yet with TS.CREATE it will be automatically created.", + "complexity": "O(M) when M is the amount of compaction rules or O(1) with no compaction", + "arguments": [{ + "name": "key", + "type": "key" + }, + { + "name": "timestamp", + "type": "integer" + }, + { + "name": "value", + "type": "double" + }, + { + "type": "integer", + "command": "RETENTION", + "name": "retentionTime", + "optional": true + }, + { + "type": "enum", + "command": "ENCODING", + "enum": [ + "UNCOMPRESSED", + "COMPRESSED" + ], + "optional": true + }, + { + "type": "integer", + "command": "CHUNK_SIZE", + "name": "size", + "optional": true + }, + { + "type": "enum", + "command": "ON_DUPLICATE", + "name": "policy", + "enum": [ + "BLOCK", + "FIRST", + "LAST", + "MIN", + "MAX", + "SUM" + ], + "optional": true + }, + { + "command": "LABELS", + "name": [ + "label", + "value" + ], + "type": [ + "string", + "string" + ], + "multiple": true, + "optional": true + } + ] + } +} diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts new file mode 100644 index 0000000000..99decbe833 --- /dev/null +++ b/redisinsight/api/src/constants/error-messages.ts @@ -0,0 +1,45 @@ +/* eslint-disable max-len */ +export default { + INVALID_DATABASE_INSTANCE_ID: 'Invalid database instance id.', + UNDEFINED_INSTANCE_ID: 'Undefined redis database instance id.', + NO_CONNECTION_TO_REDIS_DB: 'No connection to the Redis Database.', + WRONG_DATABASE_TYPE: 'Wrong database type.', + CONNECTION_TIMEOUT: + 'The connection has timed out, please check the connection details.', + AUTHENTICATION_FAILED: () => 'Failed to authenticate, please check the username or password.', + INCORRECT_DATABASE_URL: (url) => `Could not connect to ${url}, please check the connection details.`, + INCORRECT_CERTIFICATES: (url) => `Could not connect to ${url}, please check the CA or Client certificate.`, + INCORRECT_CREDENTIALS: (url) => `Could not connect to ${url}, please check the Username or Password.`, + + CA_CERT_EXIST: 'This ca certificate name is already in use.', + CLIENT_CERT_EXIST: 'This client certificate name is already in use.', + INVALID_CERTIFICATE_ID: 'Invalid certificate id.', + SENTINEL_MASTER_NAME_REQUIRED: 'Sentinel master name must be specified.', + MASTER_GROUP_NOT_EXIST: "Master group with this name doesn't exist", + + KEY_NAME_EXIST: 'This key name is already in use.', + KEY_NOT_EXIST: 'Key with this name does not exist.', + PATH_NOT_EXISTS: () => 'There is no such path.', + INDEX_OUT_OF_RANGE: () => 'Index is out of range.', + MEMBER_IN_SET_NOT_EXIST: 'This member does not exist.', + NEW_KEY_NAME_EXIST: 'New key name is already in use.', + KEY_OR_TIMEOUT_NOT_EXIST: + 'Key with this name does not exist or does not have an associated timeout.', + SERVER_NOT_AVAILABLE: 'Server is not available. Please try again later.', + REDIS_CLOUD_FORBIDDEN: 'Error fetching account details.', + + DATABASE_IS_INACTIVE: 'The base is inactive.', + + INCORRECT_CLUSTER_CURSOR_FORMAT: 'Incorrect cluster cursor format.', + REMOVING_MULTIPLE_ELEMENTS_NOT_SUPPORT: () => 'Removing multiple elements is available for Redis databases v. 6.2 or later.', + SCAN_PER_KEY_TYPE_NOT_SUPPORT: () => 'Filtering per Key types is available for Redis databases v. 6.0 or later.', + WRONG_DISCOVERY_TOOL: () => 'Selected discovery tool is incorrect, please add this database manually using Host and Port.', + COMMAND_NOT_SUPPORTED: (command: string) => `Redis does not support '${command}' command.`, + CLI_COMMAND_NOT_SUPPORTED: (command: string) => `CLI ERROR: The '${command}' command is not supported by the RedisInsight CLI.`, + CLI_UNTERMINATED_QUOTES: () => 'Invalid argument(s): Unterminated quotes.', + CLI_INVALID_QUOTES_CLOSING: () => 'Invalid argument(s): Closing quote must be followed by a space or nothing at all.', + CLUSTER_NODE_NOT_FOUND: (node: string) => `Node ${node} not exist in OSS Cluster.`, + REDIS_MODULE_IS_REQUIRED: (module: string) => `Required ${module} module is not loaded.`, + APP_SETTINGS_NOT_FOUND: () => 'Could not find application settings.', + SERVER_INFO_NOT_FOUND: () => 'Could not find server info.', +}; diff --git a/redisinsight/api/src/constants/exceptions.ts b/redisinsight/api/src/constants/exceptions.ts new file mode 100644 index 0000000000..f321ed4ba1 --- /dev/null +++ b/redisinsight/api/src/constants/exceptions.ts @@ -0,0 +1,28 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class AgreementIsNotDefinedException extends HttpException { + constructor(message) { + super( + { + statusCode: HttpStatus.BAD_REQUEST, + message, + error: 'Bad Request', + }, + HttpStatus.BAD_REQUEST, + ); + } +} + +export class ServerInfoNotFoundException extends HttpException { + constructor(message = ERROR_MESSAGES.SERVER_INFO_NOT_FOUND()) { + super( + { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message, + error: 'Internal Server Error', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } +} diff --git a/redisinsight/api/src/constants/index.ts b/redisinsight/api/src/constants/index.ts new file mode 100644 index 0000000000..29b0a50e30 --- /dev/null +++ b/redisinsight/api/src/constants/index.ts @@ -0,0 +1,11 @@ +export * from './error-messages'; +export * from './sort'; +export * from './regex'; +export * from './redis-error-codes'; +export * from './redis-keys'; +export * from './redis-modules'; +export * from './exceptions'; +export * from './redis-commands'; +export * from './telemetry-events'; +export * from './app-events'; +export * from './redis-connection'; diff --git a/redisinsight/api/src/constants/redis-commands.ts b/redisinsight/api/src/constants/redis-commands.ts new file mode 100644 index 0000000000..3b46ce340c --- /dev/null +++ b/redisinsight/api/src/constants/redis-commands.ts @@ -0,0 +1,45 @@ +export const pluginUnsupportedCommands = [ + 'role', + 'slowlog', + 'failover', + 'bgrewriteaof', + 'psync', + 'shutdown', + 'lastsave', + 'bgsave', + 'restore', + 'cluster', + 'save', + 'debug', + 'pfselftest', + 'flushdb', + 'monitor', + 'pfdebug', + 'sync', + 'slaveof', + 'flushall', + 'migrate', + 'info', + 'keys', + 'replconf', + 'config', + 'replicaof', + 'acl', + 'client', + 'sort', + 'latency', + 'restore-asking', + 'module', + 'swapdb', +]; + +export const pluginBlockingCommands = [ + 'xreadgroup', + 'bzpopmax', + 'blmove', + 'blpop', + 'bzpopmin', + 'brpoplpush', + 'xread', + 'brpop', +]; diff --git a/redisinsight/api/src/constants/redis-connection.ts b/redisinsight/api/src/constants/redis-connection.ts new file mode 100644 index 0000000000..1acf224dc4 --- /dev/null +++ b/redisinsight/api/src/constants/redis-connection.ts @@ -0,0 +1 @@ +export const CONNECTION_NAME_GLOBAL_PREFIX = 'redisinsight'; diff --git a/redisinsight/api/src/constants/redis-error-codes.ts b/redisinsight/api/src/constants/redis-error-codes.ts new file mode 100644 index 0000000000..a7123dd422 --- /dev/null +++ b/redisinsight/api/src/constants/redis-error-codes.ts @@ -0,0 +1,21 @@ +export enum RedisErrorCodes { + WrongType = 'WRONGTYPE', + NoPermission = 'NOPERM', + ConnectionRefused = 'ECONNREFUSED', + InvalidPassword = 'WRONGPASS', + AuthRequired = 'NOAUTH', + ConnectionNotFound = 'ENOTFOUND', + DNSTimeoutError = 'EAI_AGAIN', + SentinelParamsRequired = 'SENTINEL_PARAMS_REQUIRED', + ConnectionReset = 'ECONNRESET', + Timeout = 'ETIMEDOUT', + CommandSyntaxError = 'syntax error', + UnknownCommand = 'unknown command', +} + +export enum CertificatesErrorCodes { + IncorrectCertificates = 'UNCERTAIN_STATE', + DepthZeroSelfSignedCert = 'DEPTH_ZERO_SELF_SIGNED_CERT', + SelfSignedCertInChain = 'SELF_SIGNED_CERT_IN_CHAIN', + OSSLError = 'ERR_OSSL', +} diff --git a/redisinsight/api/src/constants/redis-keys.ts b/redisinsight/api/src/constants/redis-keys.ts new file mode 100644 index 0000000000..34c9a52f5d --- /dev/null +++ b/redisinsight/api/src/constants/redis-keys.ts @@ -0,0 +1 @@ +export const MAX_TTL_NUMBER = 2147483647; diff --git a/redisinsight/api/src/constants/redis-modules.ts b/redisinsight/api/src/constants/redis-modules.ts new file mode 100644 index 0000000000..1b6f966d7f --- /dev/null +++ b/redisinsight/api/src/constants/redis-modules.ts @@ -0,0 +1,49 @@ +export enum RedisModules { + RedisAI = 'ai', + RedisGraph = 'graph', + RedisGears = 'rg', + RedisBloom = 'bf', + RedisJSON = 'ReJSON', + RediSearch = 'search', + RedisTimeSeries = 'timeseries', +} + +export const SUPPORTED_REDIS_MODULES = Object.freeze({ + ai: RedisModules.RedisAI, + graph: RedisModules.RedisGraph, + rg: RedisModules.RedisGears, + bf: RedisModules.RedisBloom, + ReJSON: RedisModules.RedisJSON, + search: RedisModules.RediSearch, + timeseries: RedisModules.RedisTimeSeries, +}); + +export const RE_CLOUD_MODULES_NAMES = Object.freeze({ + RedisAI: RedisModules.RedisAI, + RedisGraph: RedisModules.RedisGraph, + RedisGears: RedisModules.RedisGears, + RedisBloom: RedisModules.RedisBloom, + RedisJSON: RedisModules.RedisJSON, + RediSearch: RedisModules.RediSearch, + RedisTimeSeries: RedisModules.RedisTimeSeries, +}); + +export const RE_CLUSTER_MODULES_NAMES = Object.freeze({ + ai: RedisModules.RedisAI, + graph: RedisModules.RedisGraph, + gears: RedisModules.RedisGears, + bf: RedisModules.RedisBloom, + ReJSON: RedisModules.RedisJSON, + search: RedisModules.RediSearch, + timeseries: RedisModules.RedisTimeSeries, +}); + +export const REDIS_MODULES_COMMANDS = new Map([ + [RedisModules.RedisAI, ['ai.info']], + [RedisModules.RedisGraph, ['graph.delete']], + [RedisModules.RedisGears, ['rg.pyexecute']], + [RedisModules.RedisBloom, ['bf.info', 'cf.info', 'cms.info', 'topk.info']], + [RedisModules.RedisJSON, ['json.get']], + [RedisModules.RediSearch, ['ft.info']], + [RedisModules.RedisTimeSeries, ['ts.mrange', 'ts.info']], +]); diff --git a/redisinsight/api/src/constants/regex.ts b/redisinsight/api/src/constants/regex.ts new file mode 100644 index 0000000000..ef68cca6cc --- /dev/null +++ b/redisinsight/api/src/constants/regex.ts @@ -0,0 +1,5 @@ +export const ARG_IN_QUOTATION_MARKS_REGEX = /"[^"]*|'[^']*'|"+/g; +export const IS_INTEGER_NUMBER_REGEX = /^\d+$/; +export const IS_NON_PRINTABLE_ASCII_CHARACTER = /[^ -~\u0007\b\t\n\r]/; +export const IP_ADDRESS_REGEX = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +export const PRIVATE_IP_ADDRESS_REGEX = /(^127\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)/; diff --git a/redisinsight/api/src/constants/sort.ts b/redisinsight/api/src/constants/sort.ts new file mode 100644 index 0000000000..cb395a8d9e --- /dev/null +++ b/redisinsight/api/src/constants/sort.ts @@ -0,0 +1,4 @@ +export enum SortOrder { + Asc = 'ASC', + Desc = 'DESC', +} diff --git a/redisinsight/api/src/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts new file mode 100644 index 0000000000..b2666b2b1a --- /dev/null +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -0,0 +1,49 @@ +export enum TelemetryEvents { + // Main events + ApplicationFirstStart = 'APPLICATION_FIRST_START', + ApplicationStarted = 'APPLICATION_STARTED', + AnalyticsPermission = 'ANALYTICS_PERMISSION', + SettingsScanThresholdChanged = 'SETTINGS_KEYS_TO_SCAN_CHANGED', + + // Events for redis instances + RedisInstanceAdded = 'CONFIG_DATABASES_DATABASE_ADDED', + RedisInstanceAddFailed = 'CONFIG_DATABASES_DATABASE_ADD_FAILED', + RedisInstanceDeleted = 'CONFIG_DATABASES_DATABASE_DELETED', + RedisInstanceEditedByUser = 'CONFIG_DATABASES_DATABASE_EDITED_BY_USER', + RedisInstanceConnectionFailed = 'DATABASE_CONNECTION_FAILED', + + // Events for autodiscovery flows + REClusterDiscoverySucceed = 'CONFIG_DATABASES_RE_CLUSTER_AUTODISCOVERY_SUCCEEDED', + REClusterDiscoveryFailed = 'CONFIG_DATABASES_RE_CLUSTER_AUTODISCOVERY_FAILED', + RECloudSubscriptionsDiscoverySucceed = 'CONFIG_DATABASES_RE_CLOUD_AUTODISCOVERY_SUBSCRIPTIONS_SUCCEEDED', + RECloudSubscriptionsDiscoveryFailed = 'CONFIG_DATABASES_RE_CLOUD_AUTODISCOVERY_SUBSCRIPTIONS_FAILED', + RECloudDatabasesDiscoverySucceed = 'CONFIG_DATABASES_RE_CLOUD_AUTODISCOVERY_DATABASES_SUCCEEDED', + RECloudDatabasesDiscoveryFailed = 'CONFIG_DATABASES_RE_CLOUD_AUTODISCOVERY_DATABASES_FAILED', + SentinelMasterGroupsDiscoverySucceed = 'CONFIG_DATABASES_REDIS_SENTINEL_AUTODISCOVERY_SUCCEEDED', + SentinelMasterGroupsDiscoveryFailed = 'CONFIG_DATABASES_REDIS_SENTINEL_AUTODISCOVERY_FAILED', + + // Events for browser tool + BrowserKeysScanned = 'BROWSER_KEYS_SCANNED', + BrowserKeysScannedWithFilters = 'BROWSER_KEYS_SCANNED_WITH_FILTER_ENABLED', + BrowserKeyAdded = 'BROWSER_KEY_ADDED', + BrowserKeyTTLChanged = 'BROWSER_KEY_TTL_CHANGED', + BrowserKeysDeleted = 'BROWSER_KEYS_DELETED', + BrowserKeyValueFiltered = 'BROWSER_KEY_VALUE_FILTERED', + BrowserKeyValueAdded = 'BROWSER_KEY_VALUE_ADDED', + BrowserKeyValueEdited = 'BROWSER_KEY_VALUE_EDITED', + BrowserKeyValueRemoved = 'BROWSER_KEY_VALUE_REMOVED', + BrowserKeyValueDeleted = 'BROWSER_KEY_VALUE_FILTERED', + BrowserJSONPropertyEdited = 'BROWSER_JSON_PROPERTY_EDITED', + BrowserJSONPropertyAdded = 'BROWSER_JSON_PROPERTY_ADDED', + BrowserJSONPropertyDeleted = 'BROWSER_JSON_PROPERTY_DELETED', + + // Events for cli tool + CliClientCreated = 'CLI_CLIENT_CREATED', + CliClientCreationFailed = 'CLI_CLIENT_CREATION_FAILED', + CliClientConnectionError = 'CLI_CLIENT_CONNECTION_ERROR', + CliClientDeleted = 'CLI_CLIENT_DELETED', + CliClientRecreated = 'CLI_CLIENT_RECREATED', + CliCommandExecuted = 'CLI_COMMAND_EXECUTED', + CliClusterNodeCommandExecuted = 'CLI_CLUSTER_COMMAND_EXECUTED', + CliCommandErrorReceived = 'CLI_COMMAND_ERROR_RECEIVED', +} diff --git a/redisinsight/api/src/controllers/server-info.controller.ts b/redisinsight/api/src/controllers/server-info.controller.ts new file mode 100644 index 0000000000..ec06d958d2 --- /dev/null +++ b/redisinsight/api/src/controllers/server-info.controller.ts @@ -0,0 +1,75 @@ +import { + Controller, + Get, + Inject, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { + getBlockingCommands, + getUnsupportedCommands, +} from 'src/utils/cli-helper'; +import { IServerProvider } from 'src/modules/core/models/server-provider.interface'; +import { GetServerInfoResponse } from 'src/dto/server.dto'; + +@ApiTags('Info') +@Controller('info') +@UsePipes(new ValidationPipe({ transform: true })) +export class ServerInfoController { + constructor( + @Inject('SERVER_PROVIDER') + private serverProvider: IServerProvider, + ) {} + + @Get('') + @ApiEndpoint({ + description: 'Get server info', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Server Info', + type: GetServerInfoResponse, + }, + ], + }) + async getInfo(): Promise { + return this.serverProvider.getInfo(); + } + + @Get('/cli-unsupported-commands') + @ApiEndpoint({ + description: 'Get list of unsupported commands in CLI', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Unsupported commands', + type: String, + isArray: true, + }, + ], + }) + async getCliUnsupportedCommands(): Promise { + return getUnsupportedCommands(); + } + + @Get('/cli-blocking-commands') + @ApiEndpoint({ + description: 'Get list of blocking commands in CLI', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Blocking commands', + type: String, + isArray: true, + }, + ], + }) + async getCliBlockingCommands(): Promise { + return getBlockingCommands(); + } +} diff --git a/redisinsight/api/src/controllers/settings.controller.ts b/redisinsight/api/src/controllers/settings.controller.ts new file mode 100644 index 0000000000..6682aa9c45 --- /dev/null +++ b/redisinsight/api/src/controllers/settings.controller.ts @@ -0,0 +1,83 @@ +import { + Body, + Controller, + Get, + Inject, + Patch, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { + GetAgreementsSpecResponse, + GetAppSettingsResponse, + UpdateSettingsDto, +} from '../dto/settings.dto'; + +@ApiTags('Settings') +@Controller('settings') +@UsePipes(new ValidationPipe({ transform: true })) +export class SettingsController { + constructor( + @Inject('SETTINGS_PROVIDER') + private settingsService: ISettingsProvider, + ) {} + + @Get('') + @ApiEndpoint({ + description: 'Get info about application settings', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Application settings', + type: GetAppSettingsResponse, + }, + ], + }) + async getSettings(): Promise { + return this.settingsService.getSettings(); + } + + @Get('/agreements/spec') + @ApiEndpoint({ + description: 'Get json with agreements specification', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Agreements specification', + type: GetAgreementsSpecResponse, + }, + ], + }) + async getAgreementsSpec(): Promise { + return this.settingsService.getAgreementsSpec(); + } + + @Patch('') + @ApiEndpoint({ + description: 'Update user application settings and agreements', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Application settings', + type: GetAppSettingsResponse, + }, + ], + }) + @UsePipes( + new ValidationPipe({ + transform: true, + whitelist: true, + }), + ) + async update( + @Body() dto: UpdateSettingsDto, + ): Promise { + return this.settingsService.updateSettings(dto); + } +} diff --git a/redisinsight/api/src/decorators/api-endpoint.decorator.ts b/redisinsight/api/src/decorators/api-endpoint.decorator.ts new file mode 100644 index 0000000000..ab65e0f205 --- /dev/null +++ b/redisinsight/api/src/decorators/api-endpoint.decorator.ts @@ -0,0 +1,20 @@ +import { applyDecorators, HttpCode } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiResponseOptions } from '@nestjs/swagger/dist/decorators/api-response.decorator'; + +export interface IApiEndpointOptions { + description: string; + statusCode?: number; + responses?: ApiResponseOptions[]; +} + +export function ApiEndpoint( + options: IApiEndpointOptions, +): MethodDecorator & ClassDecorator { + const { description, statusCode, responses = [] } = options; + return applyDecorators( + ApiOperation({ description }), + HttpCode(statusCode), + ...responses?.map((response) => ApiResponse(response)), + ); +} diff --git a/redisinsight/api/src/decorators/api-redis-instance-operation.decorator.ts b/redisinsight/api/src/decorators/api-redis-instance-operation.decorator.ts new file mode 100644 index 0000000000..96f68aac97 --- /dev/null +++ b/redisinsight/api/src/decorators/api-redis-instance-operation.decorator.ts @@ -0,0 +1,12 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; +import { + ApiEndpoint, + IApiEndpointOptions, +} from 'src/decorators/api-endpoint.decorator'; + +export function ApiRedisInstanceOperation( + options: IApiEndpointOptions, +): MethodDecorator & ClassDecorator { + return applyDecorators(ApiRedisParams(), ApiEndpoint(options)); +} diff --git a/redisinsight/api/src/decorators/api-redis-params.decorator.ts b/redisinsight/api/src/decorators/api-redis-params.decorator.ts new file mode 100644 index 0000000000..c679d42ef2 --- /dev/null +++ b/redisinsight/api/src/decorators/api-redis-params.decorator.ts @@ -0,0 +1,13 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiParam } from '@nestjs/swagger'; + +export function ApiRedisParams(): MethodDecorator & ClassDecorator { + return applyDecorators( + ApiParam({ + name: 'dbInstance', + description: 'Database instance id.', + type: String, + required: true, + }), + ); +} diff --git a/redisinsight/api/src/dto/dto-transformer.spec.ts b/redisinsight/api/src/dto/dto-transformer.spec.ts new file mode 100644 index 0000000000..f453c5c647 --- /dev/null +++ b/redisinsight/api/src/dto/dto-transformer.spec.ts @@ -0,0 +1,14 @@ +import { pickDefinedAgreements } from 'src/dto/dto-transformer'; + +describe('pickDefinedAgreements', () => { + it('should pick only agreements that defined in specification', () => { + const input = new Map([ + ['eula', true], + ['undefined', true], + ]); + + const output = pickDefinedAgreements(input); + + expect(output).toEqual(new Map([['eula', true]])); + }); +}); diff --git a/redisinsight/api/src/dto/dto-transformer.ts b/redisinsight/api/src/dto/dto-transformer.ts new file mode 100644 index 0000000000..d92e18dcb1 --- /dev/null +++ b/redisinsight/api/src/dto/dto-transformer.ts @@ -0,0 +1,14 @@ +import { isMap } from 'lodash'; +import * as AGREEMENTS_SPEC from 'src/constants/agreements-spec.json'; + +// Delete all keys from the validated Map that are not included in the settings specification. +export const pickDefinedAgreements = (data: Map) => { + if (isMap(data)) { + for (const k of data?.keys()) { + if (!AGREEMENTS_SPEC.agreements[k]) { + data.delete(k); + } + } + } + return data; +}; diff --git a/redisinsight/api/src/dto/server.dto.ts b/redisinsight/api/src/dto/server.dto.ts new file mode 100644 index 0000000000..cf7d71fb01 --- /dev/null +++ b/redisinsight/api/src/dto/server.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class GetServerInfoResponse { + @ApiProperty({ + description: 'Server identifier.', + type: String, + }) + id: string; + + @ApiProperty({ + description: 'Time of the first server launch.', + type: String, + format: 'date-time', + example: '2021-01-06T12:44:39.000Z', + }) + createDateTime: string; + + @ApiProperty({ + description: 'Version of the application.', + type: String, + example: '2.0.0', + }) + appVersion: string; + + @ApiProperty({ + description: 'The operating system platform.', + type: String, + example: 'linux', + }) + osPlatform: string; + + @ApiProperty({ + description: 'Application build type.', + type: String, + example: 'ELECTRON', + }) + buildType: string; + + @ApiProperty({ + description: 'List of available encryption strategies', + type: [String], + example: ['PLAIN', 'KEYTAR'], + }) + encryptionStrategies: string[]; +} diff --git a/redisinsight/api/src/dto/settings.dto.ts b/redisinsight/api/src/dto/settings.dto.ts new file mode 100644 index 0000000000..9221a19954 --- /dev/null +++ b/redisinsight/api/src/dto/settings.dto.ts @@ -0,0 +1,115 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsInstance, + IsInt, + IsOptional, + IsString, + Min, +} from 'class-validator'; +import { Exclude, Transform, Type } from 'class-transformer'; +import { IAgreementSpec } from 'src/models'; +import { pickDefinedAgreements } from 'src/dto/dto-transformer'; + +export class GetAgreementsSpecResponse { + @ApiProperty({ + description: 'Version of agreements specification.', + type: String, + example: '1.0.0', + }) + version: string; + + @ApiProperty({ + description: 'Agreements specification.', + type: Object, + example: { + eula: { + defaultValue: false, + required: true, + since: '1.0.0', + editable: false, + title: 'License Terms', + label: 'I have read and understood the License Terms', + }, + }, + }) + agreements: IAgreementSpec; +} + +export class GetUserAgreementsResponse { + @ApiProperty({ + description: 'Last version on agreements set by the user.', + type: String, + }) + version: string; + + eula?: boolean; + + analytics?: boolean; + + @Exclude() + encryption?: boolean; +} + +export class GetAppSettingsResponse { + @ApiProperty({ + description: 'Applied application theme.', + type: String, + example: 'DARK', + }) + theme: string; + + @ApiProperty({ + description: 'Applied the threshold for scan operation.', + type: Number, + example: 10000, + }) + scanThreshold: number; + + @ApiProperty({ + description: 'Agreements set by the user.', + type: GetUserAgreementsResponse, + example: { + version: '1.0.0', + eula: true, + analytics: true, + encryption: true, + }, + }) + agreements: GetUserAgreementsResponse; +} + +export class UpdateSettingsDto { + @ApiPropertyOptional({ + description: 'Application theme.', + type: String, + example: 'DARK', + }) + @IsOptional() + @IsString() + theme?: string; + + @ApiPropertyOptional({ + description: 'Threshold for scan operation.', + type: Number, + example: 10000, + }) + @IsOptional() + @IsInt({ always: true }) + @Min(500) + scanThreshold?: number; + + @ApiPropertyOptional({ + description: 'Agreements', + type: Map, + example: { + eula: true, + }, + }) + @IsOptional() + @Type(() => Map) + @IsInstance(Map) + @Transform(pickDefinedAgreements) + @IsBoolean({ each: true }) + agreements?: Map; +} diff --git a/redisinsight/api/src/main.ts b/redisinsight/api/src/main.ts new file mode 100644 index 0000000000..ce34af83e2 --- /dev/null +++ b/redisinsight/api/src/main.ts @@ -0,0 +1,54 @@ +import { NestFactory } from '@nestjs/core'; +import { SwaggerModule } from '@nestjs/swagger'; +import { NestApplicationOptions } from '@nestjs/common'; +import * as bodyParser from 'body-parser'; +import { WinstonModule } from 'nest-winston'; +import { AppModule } from './app.module'; +import SWAGGER_CONFIG from '../config/swagger'; +import LOGGER_CONFIG from '../config/logger'; +import config from './utils/config'; + +export default async function bootstrap() { + const serverConfig = config.get('server'); + const port = process.env.API_PORT || serverConfig.port; + const logger = WinstonModule.createLogger(LOGGER_CONFIG); + + const options: NestApplicationOptions = {}; + if (serverConfig.tls && serverConfig.tlsCert && serverConfig.tlsKey) { + options.httpsOptions = { + key: JSON.parse(`"${serverConfig.tlsKey}"`), + cert: JSON.parse(`"${serverConfig.tlsCert}"`), + }; + } + + const app = await NestFactory.create(AppModule, options); + + app.use(bodyParser.json({ limit: '512mb' })); + app.use(bodyParser.urlencoded({ limit: '512mb', extended: true })); + app.enableCors(); + app.setGlobalPrefix(serverConfig.globalPrefix); + app.useLogger(logger); + + if (process.env.APP_ENV !== 'electron') { + SwaggerModule.setup( + serverConfig.docPrefix, + app, + SwaggerModule.createDocument(app, SWAGGER_CONFIG), + ); + } + + await app.listen(port); + logger.log({ + message: `Server is running on http(s)://localhost:${port}`, + context: 'bootstrap', + }); + + process.on('SIGTERM', () => { + logger.log('SIGTERM command received. Shutting down...'); + process.exit(0); + }); +} + +if (process.env.APP_ENV !== 'electron') { + bootstrap(); +} diff --git a/redisinsight/api/src/middleware/redis-connection.middleware.ts b/redisinsight/api/src/middleware/redis-connection.middleware.ts new file mode 100644 index 0000000000..a9b17449d9 --- /dev/null +++ b/redisinsight/api/src/middleware/redis-connection.middleware.ts @@ -0,0 +1,44 @@ +import { + BadRequestException, + Injectable, + Logger, + NestMiddleware, + NotFoundException, +} from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { RedisService } from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; + +@Injectable() +export class RedisConnectionMiddleware implements NestMiddleware { + private logger = new Logger('RedisConnectionMiddleware'); + + constructor( + private redisService: RedisService, + private instancesBusinessService: InstancesBusinessService, + ) {} + + async use(req: Request, res: Response, next: NextFunction): Promise { + const { instanceIdFromReq } = RedisConnectionMiddleware.getConnectionConfigFromReq(req); + if (!instanceIdFromReq) { + this.throwError(req, ERROR_MESSAGES.UNDEFINED_INSTANCE_ID); + } + const existDatabaseInstance = await this.instancesBusinessService.exists(instanceIdFromReq); + if (!existDatabaseInstance) { + throw new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + + next(); + } + + private static getConnectionConfigFromReq(req: Request) { + return { instanceIdFromReq: req.params.dbInstance }; + } + + private throwError(req: Request, message: string) { + const { method, url } = req; + this.logger.error(`${message} ${method} ${url}`); + throw new BadRequestException(message); + } +} diff --git a/redisinsight/api/src/models/agreements.interface.ts b/redisinsight/api/src/models/agreements.interface.ts new file mode 100644 index 0000000000..cbf7ab9bd5 --- /dev/null +++ b/redisinsight/api/src/models/agreements.interface.ts @@ -0,0 +1,15 @@ +export interface IAgreement { + defaultValue: boolean; + displayInSetting: boolean; + required: boolean; + since: string; + editable: boolean; + disabled: boolean; + title: string; + label: string; + description?: string; +} + +export interface IAgreementSpec { + [key: string]: IAgreement; +} diff --git a/redisinsight/api/src/models/index.ts b/redisinsight/api/src/models/index.ts new file mode 100644 index 0000000000..09dddbe147 --- /dev/null +++ b/redisinsight/api/src/models/index.ts @@ -0,0 +1,4 @@ +export * from './redis-client'; +export * from './redis-cluster'; +export * from './redis-consumer.interface'; +export * from './agreements.interface'; diff --git a/redisinsight/api/src/models/redis-client.ts b/redisinsight/api/src/models/redis-client.ts new file mode 100644 index 0000000000..dac56f2890 --- /dev/null +++ b/redisinsight/api/src/models/redis-client.ts @@ -0,0 +1,22 @@ +export class RedisError extends Error { + name: string; + + command: any; +} +export class ReplyError extends RedisError { + previousErrors?: RedisError[]; + + code?: string; +} + +export enum AppTool { + Common = 'Common', + Browser = 'Browser', + CLI = 'CLI', +} + +export class IRedisModule { + name: string; + + ver: number; +} diff --git a/redisinsight/api/src/models/redis-cluster.ts b/redisinsight/api/src/models/redis-cluster.ts new file mode 100644 index 0000000000..924a708b93 --- /dev/null +++ b/redisinsight/api/src/models/redis-cluster.ts @@ -0,0 +1,29 @@ +export interface IRedisClusterInfo { + cluster_state: string; + cluster_slots_assigned: string; + cluster_slots_ok: string; + cluster_slots_pfail: string; + cluster_slots_fail: string; + cluster_known_nodes: string; + cluster_size: string; + cluster_current_epoch: string; + cluster_my_epoch: string; + cluster_stats_messages_sent: string; + cluster_stats_messages_received: string; +} +export interface IRedisClusterNodeAddress { + host: string; + port: number; +} + +export interface IRedisClusterNode extends IRedisClusterNodeAddress { + id: string; + replicaOf: string; + linkState: RedisClusterNodeLinkState; + slot: string; +} + +export enum RedisClusterNodeLinkState { + Connected = 'connected', + Disconnected = 'disconnected', +} diff --git a/redisinsight/api/src/models/redis-consumer.interface.ts b/redisinsight/api/src/models/redis-consumer.interface.ts new file mode 100644 index 0000000000..4694148ff4 --- /dev/null +++ b/redisinsight/api/src/models/redis-consumer.interface.ts @@ -0,0 +1,17 @@ +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { ReplyError } from 'src/models/redis-client'; + +export interface IRedisConsumer { + execCommand( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommand: any, + args: Array, + ): any; + + execPipeline( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommands: Array< + [toolCommand: any, ...args: Array] + >, + ): Promise<[ReplyError | null, any]>; +} diff --git a/redisinsight/api/src/modules/browser/browser.module.ts b/redisinsight/api/src/modules/browser/browser.module.ts new file mode 100644 index 0000000000..9163194ba8 --- /dev/null +++ b/redisinsight/api/src/modules/browser/browser.module.ts @@ -0,0 +1,61 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { RouterModule } from 'nest-router'; +import { SharedModule } from 'src/modules/shared/shared.module'; +import { RedisConnectionMiddleware } from 'src/middleware/redis-connection.middleware'; +import { HashController } from './controllers/hash/hash.controller'; +import { KeysController } from './controllers/keys/keys.controller'; +import { KeysBusinessService } from './services/keys-business/keys-business.service'; +import { StringController } from './controllers/string/string.controller'; +import { ListController } from './controllers/list/list.controller'; +import { SetController } from './controllers/set/set.controller'; +import { ZSetController } from './controllers/z-set/z-set.controller'; +import { RejsonRlController } from './controllers/rejson-rl/rejson-rl.controller'; +import { HashBusinessService } from './services/hash-business/hash-business.service'; +import { SetBusinessService } from './services/set-business/set-business.service'; +import { StringBusinessService } from './services/string-business/string-business.service'; +import { ListBusinessService } from './services/list-business/list-business.service'; +import { ZSetBusinessService } from './services/z-set-business/z-set-business.service'; +import { RejsonRlBusinessService } from './services/rejson-rl-business/rejson-rl-business.service'; +import { BrowserToolService } from './services/browser-tool/browser-tool.service'; +import { BrowserToolClusterService } from './services/browser-tool-cluster/browser-tool-cluster.service'; +import { BrowserAnalyticsService } from './services/browser-analytics/browser-analytics.service'; + +@Module({ + imports: [SharedModule], + controllers: [ + KeysController, + StringController, + ListController, + SetController, + ZSetController, + RejsonRlController, + HashController, + ], + providers: [ + KeysBusinessService, + StringBusinessService, + ListBusinessService, + SetBusinessService, + ZSetBusinessService, + RejsonRlBusinessService, + HashBusinessService, + BrowserToolService, + BrowserToolClusterService, + BrowserAnalyticsService, + ], +}) +export class BrowserModule implements NestModule { + configure(consumer: MiddlewareConsumer): any { + consumer + .apply(RedisConnectionMiddleware) + .forRoutes( + RouterModule.resolvePath(KeysController), + RouterModule.resolvePath(StringController), + RouterModule.resolvePath(HashController), + RouterModule.resolvePath(ListController), + RouterModule.resolvePath(SetController), + RouterModule.resolvePath(ZSetController), + RouterModule.resolvePath(RejsonRlController), + ); + } +} diff --git a/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts new file mode 100644 index 0000000000..32bcf4fab7 --- /dev/null +++ b/redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts @@ -0,0 +1,94 @@ +export enum BrowserToolKeysCommands { + Scan = 'scan', + Ttl = 'ttl', + Type = 'type', + Exists = 'exists', + Expire = 'expire', + Persist = 'persist', + Del = 'del', + Rename = 'rename', + RenameNX = 'renamenx', + MemoryUsage = 'memory usage', + DbSize = 'dbsize', +} + +export enum BrowserToolStringCommands { + Set = 'set', + Get = 'get', + StrLen = 'strlen', +} + +export enum BrowserToolHashCommands { + HSet = 'hset', + HGet = 'hget', + HLen = 'hlen', + HScan = 'hscan', + HDel = 'hdel', +} + +export enum BrowserToolListCommands { + LLen = 'llen', + Lrange = 'lrange', + LSet = 'lset', + LPush = 'lpush', + LPop = 'lpop', + RPush = 'rpush', + RPushX = 'rpushx', + LPushX = 'lpushx', + RPop = 'rpop', + LIndex = 'lindex', +} + +export enum BrowserToolSetCommands { + SScan = 'sscan', + SAdd = 'sadd', + SCard = 'scard', + SRem = 'srem', + SIsMember = 'sismember', +} + +export enum BrowserToolZSetCommands { + ZCard = 'zcard', + ZScan = 'zscan', + ZRange = 'zrange', + ZRevRange = 'zrevrange', + ZAdd = 'zadd', + ZRem = 'zrem', + ZScore = 'zscore', +} + +export enum BrowserToolRejsonRlCommands { + JsonDel = 'json.del', + JsonSet = 'json.set', + JsonGet = 'json.get', + JsonType = 'json.type', + JsonObjKeys = 'json.objkeys', + JsonObjLen = 'json.objlen', + JsonArrLen = 'json.arrlen', + JsonStrLen = 'json.strlen', + JsonArrAppend = 'json.arrappend', + JsonDebug = 'json.debug', +} + +export enum BrowserToolGraphCommands { + GraphQuery = 'graph.query', +} +export enum BrowserToolStreamCommands { + XLen = 'xlen', +} + +export enum BrowserToolTSCommands { + TSInfo = 'ts.info', +} + +export type BrowserToolCommands = + | BrowserToolKeysCommands + | BrowserToolStringCommands + | BrowserToolSetCommands + | BrowserToolListCommands + | BrowserToolHashCommands + | BrowserToolZSetCommands + | BrowserToolRejsonRlCommands + | BrowserToolStreamCommands + | BrowserToolGraphCommands + | BrowserToolTSCommands; diff --git a/redisinsight/api/src/modules/browser/controllers/hash/hash.controller.ts b/redisinsight/api/src/modules/browser/controllers/hash/hash.controller.ts new file mode 100644 index 0000000000..ee557c1249 --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/hash/hash.controller.ts @@ -0,0 +1,110 @@ +import { + Body, + Controller, + Delete, + HttpCode, + Param, + Post, + Put, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBody, ApiOkResponse, ApiOperation, ApiTags, +} from '@nestjs/swagger'; +import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; +import { + AddFieldsToHashDto, + CreateHashWithExpireDto, + DeleteFieldsFromHashDto, + DeleteFieldsFromHashResponse, + GetHashFieldsDto, + GetHashFieldsResponse, +} from '../../dto/hash.dto'; +import { HashBusinessService } from '../../services/hash-business/hash-business.service'; + +@ApiTags('Hash') +@Controller('hash') +export class HashController { + constructor(private hashBusinessService: HashBusinessService) {} + + @Post('') + @ApiOperation({ description: 'Set key to hold Hash data type' }) + @ApiRedisParams() + @ApiBody({ type: CreateHashWithExpireDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async createHash( + @Param('dbInstance') dbInstance: string, + @Body() dto: CreateHashWithExpireDto, + ): Promise { + return await this.hashBusinessService.createHash( + { + instanceId: dbInstance, + }, + dto, + ); + } + + // The key name can be very large, so it is better to send it in the request body + @Post('/get-fields') + @HttpCode(200) + @ApiOperation({ + description: + 'Get specified fields of the hash stored at key by cursor position', + }) + @ApiRedisParams() + @ApiOkResponse({ + description: 'Specified fields of the hash stored at key.', + type: GetHashFieldsResponse, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async getMembers( + @Param('dbInstance') dbInstance: string, + @Body() dto: GetHashFieldsDto, + ): Promise { + return await this.hashBusinessService.getFields( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Put('') + @ApiOperation({ + description: 'Add the specified fields to the Hash stored at key', + }) + @ApiRedisParams() + @ApiBody({ type: AddFieldsToHashDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async addMember( + @Param('dbInstance') dbInstance: string, + @Body() dto: AddFieldsToHashDto, + ): Promise { + return await this.hashBusinessService.addFields( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Delete('/fields') + @ApiOperation({ + description: 'Remove the specified fields from the Hash stored at key', + }) + @ApiRedisParams() + @ApiBody({ type: DeleteFieldsFromHashDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async deleteFields( + @Param('dbInstance') dbInstance: string, + @Body() dto: DeleteFieldsFromHashDto, + ): Promise { + return await this.hashBusinessService.deleteFields( + { + instanceId: dbInstance, + }, + dto, + ); + } +} diff --git a/redisinsight/api/src/modules/browser/controllers/keys/keys.controller.ts b/redisinsight/api/src/modules/browser/controllers/keys/keys.controller.ts new file mode 100644 index 0000000000..ed8bfb767d --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/keys/keys.controller.ts @@ -0,0 +1,146 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Patch, + Post, + Query, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBody, ApiOkResponse, ApiOperation, ApiTags, +} from '@nestjs/swagger'; +import { KeysBusinessService } from 'src/modules/browser/services/keys-business/keys-business.service'; +import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; +import { RedisService } from 'src/modules/core/services/redis/redis.service'; +import { + DeleteKeysDto, + DeleteKeysResponse, + GetKeyInfoDto, + GetKeysDto, + GetKeysWithDetailsResponse, + GetKeyInfoResponse, + RenameKeyDto, + RenameKeyResponse, + UpdateKeyTtlDto, + KeyTtlResponse, +} from '../../dto'; + +@ApiTags('Keys') +@Controller('keys') +export class KeysController { + constructor( + private redisService: RedisService, + private keysBusinessService: KeysBusinessService, + ) {} + + @Get('') + @ApiOperation({ description: 'Get keys by cursor position' }) + @ApiRedisParams() + @ApiOkResponse({ + description: 'Keys list', + type: GetKeysWithDetailsResponse, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async getKeys( + @Param('dbInstance') dbInstance: string, + @Query() getKeysDto: GetKeysDto, + ): Promise { + return this.keysBusinessService.getKeys( + { + instanceId: dbInstance, + }, + getKeysDto, + ); + } + + // The key name can be very large, so it is better to send it in the request body + @Post('/get-info') + @HttpCode(200) + @ApiOperation({ description: 'Get key info' }) + @ApiRedisParams() + @ApiBody({ type: GetKeyInfoDto }) + @ApiOkResponse({ + description: 'Keys info', + type: GetKeyInfoResponse, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async getKeyInfo( + @Param('dbInstance') dbInstance: string, + @Body() dto: GetKeyInfoDto, + ): Promise { + return await this.keysBusinessService.getKeyInfo( + { + instanceId: dbInstance, + }, + dto.keyName, + ); + } + + @Delete('') + @ApiOperation({ description: 'Delete key' }) + @ApiRedisParams() + @ApiBody({ type: DeleteKeysDto }) + @ApiOkResponse({ + description: 'Number of affected keys.', + type: DeleteKeysResponse, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async deleteKey( + @Param('dbInstance') dbInstance: string, + @Body() dto: DeleteKeysDto, + ): Promise { + return await this.keysBusinessService.deleteKeys( + { + instanceId: dbInstance, + }, + dto.keyNames, + ); + } + + @Patch('/name') + @ApiOperation({ description: 'Rename key' }) + @ApiRedisParams() + @ApiBody({ type: RenameKeyDto }) + @ApiOkResponse({ + description: 'New key name.', + type: RenameKeyResponse, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async renameKey( + @Param('dbInstance') dbInstance: string, + @Body() dto: RenameKeyDto, + ): Promise { + return await this.keysBusinessService.renameKey( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Patch('/ttl') + @ApiOperation({ description: 'Update the remaining time to live of a key' }) + @ApiRedisParams() + @ApiBody({ type: UpdateKeyTtlDto }) + @ApiOkResponse({ + description: 'The remaining time to live of a key.', + type: KeyTtlResponse, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async updateTtl( + @Param('dbInstance') dbInstance: string, + @Body() dto: UpdateKeyTtlDto, + ): Promise { + return await this.keysBusinessService.updateTtl( + { + instanceId: dbInstance, + }, + dto, + ); + } +} diff --git a/redisinsight/api/src/modules/browser/controllers/list/list.controller.ts b/redisinsight/api/src/modules/browser/controllers/list/list.controller.ts new file mode 100644 index 0000000000..9715b2ba07 --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/list/list.controller.ts @@ -0,0 +1,186 @@ +import { + Body, + Controller, + Delete, + HttpCode, + Param, + Patch, + Post, + Put, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBody, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; +import { ApiRedisInstanceOperation } from 'src/decorators/api-redis-instance-operation.decorator'; +import { + PushElementToListDto, + CreateListWithExpireDto, + GetListElementsDto, + GetListElementsResponse, + SetListElementDto, + SetListElementResponse, + GetListElementResponse, + KeyDto, + DeleteListElementsDto, + DeleteListElementsResponse, + PushListElementsResponse, +} from 'src/modules/browser/dto'; +import { ListBusinessService } from '../../services/list-business/list-business.service'; + +@ApiTags('List') +@Controller('list') +@UsePipes(new ValidationPipe({ transform: true })) +export class ListController { + constructor(private listBusinessService: ListBusinessService) {} + + @Post('') + @ApiOperation({ description: 'Set key to hold list data type' }) + @ApiRedisParams() + @ApiBody({ type: CreateListWithExpireDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async createList( + @Param('dbInstance') dbInstance: string, + @Body() dto: CreateListWithExpireDto, + ): Promise { + return await this.listBusinessService.createList( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Put('') + @ApiRedisInstanceOperation({ + description: 'Insert element at the head/tail of the List data type', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Length of the list after the push operation', + type: PushListElementsResponse, + }, + ], + }) + async pushElement( + @Param('dbInstance') dbInstance: string, + @Body() dto: PushElementToListDto, + ): Promise { + return await this.listBusinessService.pushElement( + { + instanceId: dbInstance, + }, + dto, + ); + } + + // The key name can be very large, so it is better to send it in the request body + @Post('/get-elements') + @HttpCode(200) + @ApiOperation({ + description: 'Get specified elements of the list stored at key', + }) + @ApiRedisParams() + @ApiOkResponse({ + description: 'Specified elements of the list stored at key.', + type: GetListElementsResponse, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async getElements( + @Param('dbInstance') dbInstance: string, + @Body() dto: GetListElementsDto, + ): Promise { + return this.listBusinessService.getElements( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Patch('') + @ApiOperation({ + description: 'Update list element by index.', + }) + @ApiRedisParams() + @ApiBody({ type: SetListElementDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async updateElement( + @Param('dbInstance') dbInstance: string, + @Body() dto: SetListElementDto, + ): Promise { + return await this.listBusinessService.setElement( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Post('/get-elements/:index') + @ApiParam({ + name: 'index', + description: + 'Zero-based index. 0 - first element, 1 - second element and so on. ' + + 'Negative indices can be used to designate elements starting at the tail of the list. ' + + 'Here, -1 means the last element', + type: Number, + required: true, + }) + @ApiRedisInstanceOperation({ + description: 'Get specified List element by index', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Specified elements of the list stored at key.', + type: GetListElementsResponse, + }, + ], + }) + async getElement( + @Param('dbInstance') dbInstance: string, + @Param('index') index: number, + @Body() dto: KeyDto, + ): Promise { + return this.listBusinessService.getElement( + { + instanceId: dbInstance, + }, + index, + dto, + ); + } + + @Delete('/elements') + @ApiRedisInstanceOperation({ + description: + 'Remove and return the elements from the tail/head of list stored at key.', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Removed elements.', + type: GetListElementsResponse, + }, + ], + }) + async deleteElement( + @Param('dbInstance') dbInstance: string, + @Body() dto: DeleteListElementsDto, + ): Promise { + return this.listBusinessService.deleteElements( + { + instanceId: dbInstance, + }, + dto, + ); + } +} diff --git a/redisinsight/api/src/modules/browser/controllers/rejson-rl/rejson-rl.controller.ts b/redisinsight/api/src/modules/browser/controllers/rejson-rl/rejson-rl.controller.ts new file mode 100644 index 0000000000..729d44aa18 --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/rejson-rl/rejson-rl.controller.ts @@ -0,0 +1,122 @@ +import { + Body, + Controller, + Delete, + Param, + Patch, + Post, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { + GetRejsonRlDto, + GetRejsonRlResponseDto, + CreateRejsonRlWithExpireDto, + ModifyRejsonRlSetDto, + ModifyRejsonRlArrAppendDto, + RemoveRejsonRlDto, + RemoveRejsonRlResponse, +} from 'src/modules/browser/dto'; +import { RejsonRlBusinessService } from 'src/modules/browser/services/rejson-rl-business/rejson-rl-business.service'; +import { ApiRedisInstanceOperation } from 'src/decorators/api-redis-instance-operation.decorator'; + +@ApiTags('REJSON-RL') +@Controller('rejson-rl') +@UsePipes(new ValidationPipe({ transform: true })) +export class RejsonRlController { + constructor(private service: RejsonRlBusinessService) {} + + @Post('/get') + @ApiRedisInstanceOperation({ + description: 'Get json properties by path', + statusCode: 200, + responses: [ + { + status: 200, + description: + 'Download full data by path or returns description of data inside', + type: GetRejsonRlResponseDto, + }, + ], + }) + async getJson( + @Param('dbInstance') dbInstance: string, + @Body() dto: GetRejsonRlDto, + ): Promise { + return this.service.getJson( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Post('') + @ApiRedisInstanceOperation({ + description: 'Create new REJSON-RL data type', + statusCode: 201, + }) + async createJson( + @Param('dbInstance') dbInstance: string, + @Body() dto: CreateRejsonRlWithExpireDto, + ): Promise { + return this.service.create( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Patch('/set') + @ApiRedisInstanceOperation({ + description: 'Modify REJSON-RL data type by path', + statusCode: 200, + }) + async jsonSet( + @Param('dbInstance') dbInstance: string, + @Body() dto: ModifyRejsonRlSetDto, + ): Promise { + return this.service.jsonSet( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Patch('/arrappend') + @ApiRedisInstanceOperation({ + description: 'Append item inside REJSON-RL array', + statusCode: 200, + }) + async arrAppend( + @Param('dbInstance') dbInstance: string, + @Body() dto: ModifyRejsonRlArrAppendDto, + ): Promise { + return this.service.arrAppend( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Delete('') + @ApiRedisInstanceOperation({ + description: 'Removes path in the REJSON-RL', + statusCode: 200, + }) + async remove( + @Param('dbInstance') dbInstance: string, + @Body() dto: RemoveRejsonRlDto, + ): Promise { + return this.service.remove( + { + instanceId: dbInstance, + }, + dto, + ); + } +} diff --git a/redisinsight/api/src/modules/browser/controllers/set/set.controller.ts b/redisinsight/api/src/modules/browser/controllers/set/set.controller.ts new file mode 100644 index 0000000000..f9d71eaf47 --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/set/set.controller.ts @@ -0,0 +1,110 @@ +import { + Body, + Controller, + Delete, + HttpCode, + Param, + Post, + Put, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBody, ApiOkResponse, ApiOperation, ApiTags, +} from '@nestjs/swagger'; +import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; +import { + AddMembersToSetDto, + CreateSetWithExpireDto, + DeleteMembersFromSetDto, + DeleteMembersFromSetResponse, + GetSetMembersDto, + GetSetMembersResponse, +} from '../../dto'; +import { SetBusinessService } from '../../services/set-business/set-business.service'; + +@ApiTags('Set') +@Controller('set') +export class SetController { + constructor(private setBusinessService: SetBusinessService) {} + + @Post('') + @ApiOperation({ description: 'Set key to hold Set data type' }) + @ApiRedisParams() + @ApiBody({ type: CreateSetWithExpireDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async createSet( + @Param('dbInstance') dbInstance: string, + @Body() dto: CreateSetWithExpireDto, + ): Promise { + return await this.setBusinessService.createSet( + { + instanceId: dbInstance, + }, + dto, + ); + } + + // The key name can be very large, so it is better to send it in the request body + @Post('/get-members') + @HttpCode(200) + @ApiOperation({ + description: + 'Get specified members of the set stored at key by cursor position', + }) + @ApiRedisParams() + @ApiOkResponse({ + description: 'Specified members of the set stored at key.', + type: GetSetMembersResponse, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async getMembers( + @Param('dbInstance') dbInstance: string, + @Body() dto: GetSetMembersDto, + ): Promise { + return await this.setBusinessService.getMembers( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Put('') + @ApiOperation({ + description: 'Add the specified members to the Set stored at key', + }) + @ApiRedisParams() + @ApiBody({ type: AddMembersToSetDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async addMembers( + @Param('dbInstance') dbInstance: string, + @Body() dto: AddMembersToSetDto, + ): Promise { + return await this.setBusinessService.addMembers( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Delete('/members') + @ApiOperation({ + description: 'Remove the specified members from the Set stored at key', + }) + @ApiRedisParams() + @ApiBody({ type: DeleteMembersFromSetDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async deleteMembers( + @Param('dbInstance') dbInstance: string, + @Body() dto: DeleteMembersFromSetDto, + ): Promise { + return await this.setBusinessService.deleteMembers( + { + instanceId: dbInstance, + }, + dto, + ); + } +} diff --git a/redisinsight/api/src/modules/browser/controllers/string/string.controller.ts b/redisinsight/api/src/modules/browser/controllers/string/string.controller.ts new file mode 100644 index 0000000000..912547d49c --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/string/string.controller.ts @@ -0,0 +1,84 @@ +import { + Body, + Controller, + HttpCode, + Param, + Post, + Put, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBody, ApiOkResponse, ApiOperation, ApiTags, +} from '@nestjs/swagger'; +import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; +import { + SetStringDto, + GetStringValueResponse, + SetStringWithExpireDto, +} from 'src/modules/browser/dto/string.dto'; +import { GetKeyInfoDto } from 'src/modules/browser/dto'; +import { StringBusinessService } from '../../services/string-business/string-business.service'; + +@ApiTags('String') +@Controller('string') +export class StringController { + constructor(private stringBusinessService: StringBusinessService) {} + + @Post('') + @ApiOperation({ description: 'Set key to hold string value' }) + @ApiRedisParams() + @ApiBody({ type: SetStringWithExpireDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async setString( + @Param('dbInstance') dbInstance: string, + @Body() stringDto: SetStringWithExpireDto, + ): Promise { + return this.stringBusinessService.setString( + { + instanceId: dbInstance, + }, + stringDto, + ); + } + + // The key name can be very large, so it is better to send it in the request body + @Post('/get-value') + @HttpCode(200) + @ApiOperation({ description: 'Get string value' }) + @ApiRedisParams() + @ApiBody({ type: GetKeyInfoDto }) + @ApiOkResponse({ + description: 'String value', + type: GetStringValueResponse, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async getStringValue( + @Param('dbInstance') dbInstance: string, + @Body() getKeyInfoDto: GetKeyInfoDto, + ): Promise { + return this.stringBusinessService.getStringValue( + { + instanceId: dbInstance, + }, + getKeyInfoDto.keyName, + ); + } + + @Put('') + @ApiOperation({ description: 'Update string value' }) + @ApiRedisParams() + @ApiBody({ type: SetStringDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async updateStringValue( + @Param('dbInstance') dbInstance: string, + @Body() setStringDto: SetStringDto, + ): Promise { + return this.stringBusinessService.updateStringValue( + { + instanceId: dbInstance, + }, + setStringDto, + ); + } +} diff --git a/redisinsight/api/src/modules/browser/controllers/z-set/z-set.controller.ts b/redisinsight/api/src/modules/browser/controllers/z-set/z-set.controller.ts new file mode 100644 index 0000000000..996fe90614 --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/z-set/z-set.controller.ts @@ -0,0 +1,157 @@ +import { + Body, + Controller, + Delete, + Param, + Patch, + Post, + Put, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiRedisInstanceOperation } from 'src/decorators/api-redis-instance-operation.decorator'; +import { + AddMembersToZSetDto, + CreateZSetWithExpireDto, + DeleteMembersFromZSetDto, + DeleteMembersFromZSetResponse, + GetZSetMembersDto, + GetZSetResponse, + SearchZSetMembersDto, + SearchZSetMembersResponse, + UpdateMemberInZSetDto, +} from '../../dto'; +import { ZSetBusinessService } from '../../services/z-set-business/z-set-business.service'; + +@ApiTags('ZSet') +@Controller('/zSet') +@UsePipes(new ValidationPipe({ transform: true })) +export class ZSetController { + constructor(private zSetBusinessService: ZSetBusinessService) {} + + @Post('') + @ApiRedisInstanceOperation({ + description: 'Set key to hold ZSet data type', + statusCode: 201, + }) + async createSet( + @Param('dbInstance') dbInstance: string, + @Body() dto: CreateZSetWithExpireDto, + ): Promise { + return await this.zSetBusinessService.createZSet( + { + instanceId: dbInstance, + }, + dto, + ); + } + + // The key name can be very large, so it is better to send it in the request body + @Post('/get-members') + @ApiRedisInstanceOperation({ + description: 'Get specified members of the ZSet stored at key', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Ok', + type: GetZSetResponse, + }, + ], + }) + async getZSet( + @Param('dbInstance') dbInstance: string, + @Body() dto: GetZSetMembersDto, + ): Promise { + return await this.zSetBusinessService.getMembers( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Put('') + @ApiRedisInstanceOperation({ + description: 'Add the specified members to the ZSet stored at key', + statusCode: 200, + }) + async addMembers( + @Param('dbInstance') dbInstance: string, + @Body() dto: AddMembersToZSetDto, + ): Promise { + return await this.zSetBusinessService.addMembers( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Patch('') + @ApiRedisInstanceOperation({ + description: 'Update the specified member in the ZSet stored at key', + statusCode: 200, + }) + async updateMember( + @Param('dbInstance') dbInstance: string, + @Body() dto: UpdateMemberInZSetDto, + ): Promise { + return await this.zSetBusinessService.updateMember( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Delete('/members') + @ApiRedisInstanceOperation({ + description: 'Remove the specified members from the Set stored at key', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Ok', + type: DeleteMembersFromZSetResponse, + }, + ], + }) + async deleteMembers( + @Param('dbInstance') dbInstance: string, + @Body() dto: DeleteMembersFromZSetDto, + ): Promise { + return await this.zSetBusinessService.deleteMembers( + { + instanceId: dbInstance, + }, + dto, + ); + } + + // The key name can be very large, so it is better to send it in the request body + @Post('/search') + @ApiRedisInstanceOperation({ + description: 'Search members in ZSet stored at key', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Ok', + type: SearchZSetMembersResponse, + }, + ], + }) + async searchZSet( + @Param('dbInstance') dbInstance: string, + @Body() dto: SearchZSetMembersDto, + ): Promise { + return await this.zSetBusinessService.searchMembers( + { + instanceId: dbInstance, + }, + dto, + ); + } +} diff --git a/redisinsight/api/src/modules/browser/dto/hash.dto.ts b/redisinsight/api/src/modules/browser/dto/hash.dto.ts new file mode 100644 index 0000000000..ec833cc79e --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/hash.dto.ts @@ -0,0 +1,106 @@ +import { + KeyDto, + KeyWithExpireDto, + ScanDataTypeDto, +} from 'src/modules/browser/dto/keys.dto'; +import { ApiProperty, IntersectionType } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsDefined, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class HashFieldDto { + @ApiProperty({ + description: 'Field', + type: String, + }) + @IsDefined() + @IsString() + field: string; + + @ApiProperty({ + description: 'Field', + type: String, + }) + @IsDefined() + @IsString() + value: string; +} + +export class AddFieldsToHashDto extends KeyDto { + @ApiProperty({ + description: 'Hash fields', + isArray: true, + type: HashFieldDto, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @ValidateNested() + @Type(() => HashFieldDto) + fields: HashFieldDto[]; +} + +export class CreateHashWithExpireDto extends IntersectionType( + AddFieldsToHashDto, + KeyWithExpireDto, +) {} + +export class GetHashFieldsDto extends ScanDataTypeDto {} + +export class HashScanResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + type: Number, + minimum: 0, + description: + 'The new cursor to use in the next call.' + + ' If the property has value of 0, then the iteration is completed.', + }) + nextCursor: number; + + @ApiProperty({ + type: () => HashFieldDto, + description: 'Array of members.', + isArray: true, + }) + fields: HashFieldDto[]; +} + +export class GetHashFieldsResponse extends HashScanResponse { + @ApiProperty({ + type: Number, + description: 'The number of fields in the currently-selected hash.', + }) + total: number; +} + +export class DeleteFieldsFromHashDto extends KeyDto { + @ApiProperty({ + description: 'Hash fields', + type: String, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @Type(() => String) + fields: string[]; +} + +export class DeleteFieldsFromHashResponse { + @ApiProperty({ + description: 'Number of affected fields', + type: Number, + }) + affected: number; +} diff --git a/redisinsight/api/src/modules/browser/dto/index.ts b/redisinsight/api/src/modules/browser/dto/index.ts new file mode 100644 index 0000000000..652e0a03cd --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/index.ts @@ -0,0 +1,7 @@ +export * from './keys.dto'; +export * from './string.dto'; +export * from './list.dto'; +export * from './set.dto'; +export * from './hash.dto'; +export * from './z-set.dto'; +export * from './rejson-rl.dto'; diff --git a/redisinsight/api/src/modules/browser/dto/keys.dto.ts b/redisinsight/api/src/modules/browser/dto/keys.dto.ts new file mode 100644 index 0000000000..256c1a7f43 --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/keys.dto.ts @@ -0,0 +1,301 @@ +import { + ArrayNotEmpty, + IsArray, + IsDefined, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ApiProperty, + ApiPropertyOptional, +} from '@nestjs/swagger'; +import { MAX_TTL_NUMBER } from 'src/constants/redis-keys'; + +export enum RedisDataType { + String = 'string', + Hash = 'hash', + List = 'list', + Set = 'set', + ZSet = 'zset', + Stream = 'stream', + JSON = 'ReJSON-RL', + Graph = 'graphdata', + TS = 'TSDB-TYPE', +} + +export class KeyDto { + @ApiProperty({ + description: 'Key Name', + type: String, + }) + @IsDefined() + @IsString() + keyName: string; +} + +export class KeyWithExpireDto extends KeyDto { + @ApiPropertyOptional({ + type: Number, + description: + 'Set a timeout on key in seconds. After the timeout has expired, the key will automatically be deleted.', + minimum: 1, + maximum: MAX_TTL_NUMBER, + }) + @IsOptional() + @IsInt({ always: true }) + @Min(1) + @Max(MAX_TTL_NUMBER) + expire?: number; +} + +export class ScanDataTypeDto extends KeyDto { + @ApiProperty({ + description: + 'Iteration cursor. ' + + 'An iteration starts when the cursor is set to 0, and terminates when the cursor returned by the server is 0.', + type: Number, + minimum: 0, + default: 0, + }) + @IsInt() + @Min(0) + @Type(() => Number) + @IsNotEmpty() + cursor: number; + + @ApiPropertyOptional({ + description: 'Specifying the number of elements to return.', + type: Number, + minimum: 1, + default: 15, + }) + @IsInt() + @Min(1) + @Type(() => Number) + @IsNotEmpty() + @IsOptional() + count?: number; + + @ApiPropertyOptional({ + description: 'Iterate only elements matching a given pattern.', + type: String, + default: '*', + }) + @IsString() + @IsOptional() + match?: string; +} + +export class GetKeysDto { + @ApiProperty({ + description: + 'Iteration cursor. ' + + 'An iteration starts when the cursor is set to 0, and terminates when the cursor returned by the server is 0.', + type: String, + default: '0', + }) + @Type(() => String) + @IsNotEmpty() + cursor: string; + + @ApiPropertyOptional({ + description: 'Specifying the number of elements to return.', + type: Number, + minimum: 1, + default: 15, + }) + @IsInt() + @Min(1) + @Type(() => Number) + @IsNotEmpty() + @IsOptional() + count?: number; + + @ApiPropertyOptional({ + description: 'Iterate only elements matching a given pattern.', + type: String, + default: '*', + }) + @IsString() + @IsOptional() + match?: string; + + @ApiPropertyOptional({ + description: + 'Iterate through the database looking for keys of a specific type.', + enum: RedisDataType, + }) + @IsEnum(RedisDataType, { + message: `destination must be a valid enum value. Valid values: ${Object.values( + RedisDataType, + )}.`, + }) + @IsOptional() + type?: RedisDataType; +} + +export class GetKeyInfoDto extends KeyDto {} + +export class DeleteKeysDto { + @ApiProperty({ + description: 'Key name', + type: String, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @Type(() => String) + keyNames: string[]; +} + +export class DeleteKeysResponse { + @ApiProperty({ + description: 'Number of affected keys', + type: Number, + }) + affected: number; +} + +export class RenameKeyDto { + @ApiProperty({ + description: 'Key name', + type: String, + }) + @IsDefined() + @IsString() + keyName: string; + + @ApiProperty({ + description: 'New key name', + type: String, + }) + @IsDefined() + @IsString() + newKeyName: string; +} + +export class RenameKeyResponse { + @ApiProperty({ + description: 'Key name', + type: String, + }) + keyName: string; +} + +export class UpdateKeyTtlDto { + @ApiProperty({ + description: 'Key name', + type: String, + }) + @IsDefined() + @IsString() + keyName: string; + + @ApiProperty({ + type: Number, + description: + 'Set a timeout on key in seconds. After the timeout has expired, the key will automatically be deleted.' + + 'If the property has value of -1, then the key timeout will be removed.', + maximum: MAX_TTL_NUMBER, + }) + @IsNotEmpty() + @IsInt({ always: true }) + @Max(MAX_TTL_NUMBER) + ttl: number; +} + +export class KeyTtlResponse { + @ApiProperty({ + type: Number, + description: + 'The remaining time to live of a key that has a timeout. ' + + 'If value equals -2 then the key does not exist or has deleted.' + + 'If value equals -1 then the key has no associated expire (No limit).', + maximum: MAX_TTL_NUMBER, + }) + ttl: number; +} + +export class GetKeyInfoResponse { + @ApiProperty({ + type: String, + }) + name: string; + + @ApiProperty({ + type: String, + }) + type: string; + + @ApiProperty({ + type: Number, + description: + 'The remaining time to live of a key.' + + ' If the property has value of -1, then the key has no expiration time (no limit).', + }) + ttl: number; + + @ApiProperty({ + type: Number, + description: + 'The number of bytes that a key and its value require to be stored in RAM.', + }) + size: number; + + @ApiPropertyOptional({ + type: Number, + description: 'The length of the value stored in a key.', + }) + length?: number; +} + +export class GetKeysWithDetailsResponse { + @ApiProperty({ + type: Number, + default: 0, + description: + 'The new cursor to use in the next call.' + + ' If the property has value of 0, then the iteration is completed.', + }) + cursor: number; + + @ApiProperty({ + type: Number, + description: 'The number of keys in the currently-selected database.', + }) + total: number; + + @ApiProperty({ + type: Number, + description: + 'The number of keys we tried to scan. Be aware that ' + + 'scanned is sum of COUNT parameters from redis commands', + }) + scanned: number; + + @ApiProperty({ + type: () => GetKeyInfoResponse, + description: 'Array of Keys.', + isArray: true, + }) + keys: GetKeyInfoResponse[]; + + @ApiPropertyOptional({ + type: String, + description: 'Node host. In case when we are working with cluster', + }) + host?: string; + + @ApiPropertyOptional({ + type: Number, + description: 'Node port. In case when we are working with cluster', + }) + port?: number; +} diff --git a/redisinsight/api/src/modules/browser/dto/list.dto.ts b/redisinsight/api/src/modules/browser/dto/list.dto.ts new file mode 100644 index 0000000000..8979eac122 --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/list.dto.ts @@ -0,0 +1,199 @@ +import { + ApiProperty, + ApiPropertyOptional, + IntersectionType, +} from '@nestjs/swagger'; +import { + IsDefined, + IsEnum, + IsInt, + IsNotEmpty, + IsString, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { KeyDto, KeyWithExpireDto } from './keys.dto'; + +export enum ListElementDestination { + Tail = 'TAIL', + Head = 'HEAD', +} + +export class PushElementToListDto extends KeyDto { + @ApiProperty({ + description: 'List element', + type: String, + }) + @IsDefined() + @IsString() + element: string; + + @ApiPropertyOptional({ + description: + 'In order to append elements to the end of the list, ' + + 'use the TAIL value, to prepend use HEAD value. ' + + 'Default: TAIL (when not specified)', + default: ListElementDestination.Tail, + enum: ListElementDestination, + }) + @IsEnum(ListElementDestination, { + message: `destination must be a valid enum value. Valid values: ${Object.values( + ListElementDestination, + )}.`, + }) + destination: ListElementDestination = ListElementDestination.Tail; +} + +export class PushListElementsResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + type: Number, + description: 'The number of elements in the list after current operation.', + }) + total: number; +} + +export class SetListElementDto extends KeyDto { + @ApiProperty({ + description: 'List element', + type: String, + }) + @IsDefined() + @IsString() + element: string; + + @ApiProperty({ + description: 'Element index', + type: Number, + minimum: 0, + }) + @IsDefined() + @Min(0) + @IsInt({ always: true }) + @IsNotEmpty() + index: number; +} + +export class SetListElementResponse { + @ApiProperty({ + description: 'Element index', + type: Number, + minimum: 0, + }) + index: number; + + @ApiProperty({ + description: 'List element', + type: String, + }) + element: string; +} + +export class CreateListWithExpireDto extends IntersectionType( + PushElementToListDto, + KeyWithExpireDto, +) {} + +export class GetListElementsDto extends KeyDto { + @ApiProperty({ + description: 'Specifying the number of elements to skip.', + type: Number, + minimum: 0, + default: '0', + }) + @IsInt() + @Min(0) + @Type(() => Number) + @IsNotEmpty() + offset: number; + + @ApiProperty({ + description: + 'Specifying the number of elements to return from starting at offset.', + type: Number, + minimum: 1, + default: 15, + }) + @IsInt() + @Min(1) + @Type(() => Number) + @IsNotEmpty() + count: number; +} + +export class DeleteListElementsDto extends KeyDto { + @ApiProperty({ + description: + 'In order to remove last elements of the list, use the TAIL value, else HEAD value', + default: ListElementDestination.Tail, + enum: ListElementDestination, + }) + @IsDefined() + @IsEnum(ListElementDestination, { + message: `destination must be a valid enum value. Valid values: ${Object.values( + ListElementDestination, + )}.`, + }) + destination: ListElementDestination; + + @ApiProperty({ + description: 'Specifying the number of elements to remove from list.', + type: Number, + minimum: 1, + default: 1, + }) + @IsInt() + @Min(1) + @Type(() => Number) + @IsNotEmpty() + count: number; +} + +export class GetListElementsResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + type: Number, + description: 'The number of elements in the currently-selected list.', + }) + total: number; + + @ApiProperty({ + type: () => String, + description: 'Array of elements.', + isArray: true, + }) + elements: string[]; +} + +export class GetListElementResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + type: () => String, + description: 'Element value', + }) + value: string; +} + +export class DeleteListElementsResponse { + @ApiProperty({ + type: String, + isArray: true, + description: 'Removed elements from list', + }) + elements: string[]; +} diff --git a/redisinsight/api/src/modules/browser/dto/rejson-rl.dto.ts b/redisinsight/api/src/modules/browser/dto/rejson-rl.dto.ts new file mode 100644 index 0000000000..29def65a73 --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/rejson-rl.dto.ts @@ -0,0 +1,181 @@ +import { + ApiProperty, + ApiPropertyOptional, + IntersectionType, +} from '@nestjs/swagger'; +import { KeyDto, KeyWithExpireDto } from 'src/modules/browser/dto/keys.dto'; +import { + IsArray, + IsBoolean, + IsNotEmpty, + IsString, + Validate, +} from 'class-validator'; +import { SerializedJsonValidator } from 'src/validators'; + +export class GetRejsonRlDto extends KeyDto { + @ApiPropertyOptional({ + type: String, + description: 'Path to look for data', + }) + @IsString() + @IsNotEmpty() + path?: string = '.'; + + @ApiPropertyOptional({ + type: Boolean, + description: + "Don't check for json size and return whole json in path when enabled", + }) + @IsBoolean() + forceRetrieve?: boolean; +} + +enum RejsonRlDataType { + String = 'string', + Number = 'number', + Integer = 'integer', + Boolean = 'boolean', + Null = 'null', + Array = 'array', + Object = 'object', +} + +export class SafeRejsonRlDataDtO { + @ApiProperty({ + type: String, + description: 'Key inside json data', + }) + key: string; + + @ApiProperty({ + type: String, + description: 'Path of the json field', + }) + path: string; + + @ApiPropertyOptional({ + type: Number, + description: + 'Number of properties/elements inside field (for object and arrays only)', + }) + cardinality?: number; + + @ApiProperty({ + enum: RejsonRlDataType, + description: 'Type of the field', + }) + type: RejsonRlDataType; + + @ApiPropertyOptional({ + type: String, + description: 'Any value', + }) + value?: string | number | boolean | null; +} + +export class GetRejsonRlResponseDto { + @ApiProperty({ + type: Boolean, + description: 'Determines if json value was downloaded', + }) + downloaded: boolean; + + @ApiPropertyOptional({ + type: String, + description: 'Type of data in the requested path', + }) + type?: string; + + @ApiPropertyOptional({ + type: String, + description: 'Requested path', + }) + path?: string; + + @ApiProperty({ + type: () => SafeRejsonRlDataDtO, + isArray: true, + }) + data: SafeRejsonRlDataDtO[] | string | number | boolean | null; +} + +// ======================= Create DTOs +export class CreateRejsonRlDto extends KeyDto { + @ApiProperty({ + description: 'Valid json string', + type: String, + }) + @IsNotEmpty() + @IsString() + @Validate(SerializedJsonValidator) + data: string; +} + +export class CreateRejsonRlWithExpireDto extends IntersectionType( + CreateRejsonRlDto, + KeyWithExpireDto, +) {} + +// ======================= Modify [JSON.SET] DTOs +export class ModifyRejsonRlSetDto extends KeyDto { + @ApiProperty({ + type: String, + description: 'Path of the json field', + }) + @IsString() + @IsNotEmpty() + path: string; + + @ApiProperty({ + description: 'Array of valid serialized jsons', + type: String, + }) + @Validate(SerializedJsonValidator) + @IsNotEmpty() + @IsString() + data: string; +} + +// ======================= Modify [JSON.ARRAPPEND] DTOs +export class ModifyRejsonRlArrAppendDto extends KeyDto { + @ApiProperty({ + type: String, + description: 'Path of the json field', + }) + @IsString() + @IsNotEmpty() + path: string; + + @ApiProperty({ + description: 'Array of valid serialized jsons', + type: String, + isArray: true, + }) + @IsArray() + @Validate(SerializedJsonValidator, { + each: true, + }) + @IsNotEmpty({ each: true }) + @IsString({ each: true }) + data: string[]; +} + +// ======================= Remove [JSON.DEL] DTOs +export class RemoveRejsonRlDto extends KeyDto { + @ApiProperty({ + type: String, + description: 'Path of the json field', + }) + @IsString() + @IsNotEmpty() + path: string; +} + +export class RemoveRejsonRlResponse { + @ApiProperty({ + description: 'Integer , specifically the number of paths deleted (0 or 1).', + type: Number, + }) + affected: number; +} diff --git a/redisinsight/api/src/modules/browser/dto/set.dto.ts b/redisinsight/api/src/modules/browser/dto/set.dto.ts new file mode 100644 index 0000000000..158d58a74c --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/set.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty, IntersectionType } from '@nestjs/swagger'; +import { + ArrayNotEmpty, IsArray, IsDefined, IsString, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { KeyDto, KeyWithExpireDto, ScanDataTypeDto } from './keys.dto'; + +export class AddMembersToSetDto extends KeyDto { + @ApiProperty({ + description: 'Set members', + isArray: true, + type: String, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + members: string[]; +} + +export class DeleteMembersFromSetDto extends KeyDto { + @ApiProperty({ + description: 'Key members', + type: String, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @Type(() => String) + members: string[]; +} + +export class CreateSetWithExpireDto extends IntersectionType( + AddMembersToSetDto, + KeyWithExpireDto, +) {} + +export class DeleteMembersFromSetResponse { + @ApiProperty({ + description: 'Number of affected members', + type: Number, + }) + affected: number; +} + +export class GetSetMembersDto extends ScanDataTypeDto {} + +export class SetScanResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + type: Number, + minimum: 0, + description: + 'The new cursor to use in the next call.' + + ' If the property has value of 0, then the iteration is completed.', + }) + nextCursor: number; + + @ApiProperty({ + type: () => String, + description: 'Array of members.', + isArray: true, + }) + members: string[]; +} + +export class GetSetMembersResponse extends SetScanResponse { + @ApiProperty({ + type: Number, + description: 'The number of members in the currently-selected set.', + }) + total: number; +} diff --git a/redisinsight/api/src/modules/browser/dto/string.dto.ts b/redisinsight/api/src/modules/browser/dto/string.dto.ts new file mode 100644 index 0000000000..db10d6a176 --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/string.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, IntersectionType } from '@nestjs/swagger'; +import { IsDefined, IsString } from 'class-validator'; +import { KeyDto, KeyWithExpireDto } from './keys.dto'; + +export class SetStringDto extends KeyDto { + @ApiProperty({ + description: 'Key value', + type: String, + }) + @IsDefined() + @IsString() + value: string; +} + +export class SetStringWithExpireDto extends IntersectionType( + SetStringDto, + KeyWithExpireDto, +) {} + +export class GetStringValueResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + description: 'Key value', + type: String, + }) + @IsString() + value: string; +} diff --git a/redisinsight/api/src/modules/browser/dto/z-set.dto.ts b/redisinsight/api/src/modules/browser/dto/z-set.dto.ts new file mode 100644 index 0000000000..4ed0199eb7 --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/z-set.dto.ts @@ -0,0 +1,186 @@ +import { ApiProperty, IntersectionType, PickType } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsDefined, + IsEnum, + IsInt, + IsNotEmpty, + IsNotEmptyObject, + IsNumber, + IsString, + Min, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { SortOrder } from 'src/constants'; +import { + DeleteMembersFromSetDto, + DeleteMembersFromSetResponse, +} from 'src/modules/browser/dto/set.dto'; +import { KeyDto, KeyWithExpireDto, ScanDataTypeDto } from './keys.dto'; + +export class GetZSetMembersDto extends KeyDto { + @ApiProperty({ + description: 'Specifying the number of elements to skip.', + type: Number, + minimum: 0, + default: '0', + }) + @IsInt() + @Min(0) + @Type(() => Number) + @IsNotEmpty() + offset: number; + + @ApiProperty({ + description: + 'Specifying the number of elements to return from starting at offset.', + type: Number, + minimum: 1, + default: 15, + }) + @IsInt() + @Min(1) + @Type(() => Number) + @IsNotEmpty() + count: number; + + @ApiProperty({ + description: + 'Get elements sorted by score.' + + ' In order to sort the members from the highest to the lowest score, use the DESC value, else ASC value', + default: SortOrder.Desc, + enum: SortOrder, + }) + @IsNotEmpty() + @IsEnum(SortOrder, { + message: `sortOrder must be a valid enum value. Valid values: ${Object.values( + SortOrder, + )}.`, + }) + sortOrder: SortOrder; +} + +export class ZSetMemberDto { + @ApiProperty({ + type: String, + description: 'Member name value.', + }) + @IsDefined() + @IsString() + name: string; + + @ApiProperty({ + description: 'Member score value.', + type: Number, + default: 1, + }) + @IsDefined() + @IsNumber({ maxDecimalPlaces: 15 }) + @Type(() => Number) + score: number; +} + +export class AddMembersToZSetDto extends KeyDto { + @ApiProperty({ + description: 'ZSet members', + isArray: true, + type: ZSetMemberDto, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @ValidateNested() + @Type(() => ZSetMemberDto) + members: ZSetMemberDto[]; +} + +export class CreateZSetWithExpireDto extends IntersectionType( + AddMembersToZSetDto, + KeyWithExpireDto, +) {} + +export class UpdateMemberInZSetDto extends KeyDto { + @ApiProperty({ + description: 'ZSet member', + type: ZSetMemberDto, + }) + @IsDefined() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => ZSetMemberDto) + member: ZSetMemberDto; +} + +export class DeleteMembersFromZSetDto extends DeleteMembersFromSetDto {} + +export class SearchZSetMembersDto extends PickType(ScanDataTypeDto, [ + 'keyName', + 'count', + 'cursor', +] as const) { + @ApiProperty({ + description: 'Iterate only elements matching a given pattern.', + type: String, + default: '*', + }) + @IsDefined() + @IsString() + match: string; +} + +export class DeleteMembersFromZSetResponse extends DeleteMembersFromSetResponse {} + +export class GetZSetResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + type: Number, + description: 'The number of members in the currently-selected z-set.', + }) + total: number; + + @ApiProperty({ + description: 'Array of Members.', + isArray: true, + type: () => ZSetMemberDto, + }) + members: ZSetMemberDto[]; +} + +export class ScanZSetResponse { + @ApiProperty({ + type: String, + description: 'Key Name', + }) + keyName: string; + + @ApiProperty({ + type: Number, + minimum: 0, + description: + 'The new cursor to use in the next call.' + + ' If the property has value of 0, then the iteration is completed.', + }) + nextCursor: number; + + @ApiProperty({ + description: 'Array of Members.', + isArray: true, + type: () => ZSetMemberDto, + }) + members: ZSetMemberDto[]; +} + +export class SearchZSetMembersResponse extends ScanZSetResponse { + @ApiProperty({ + type: Number, + description: 'The number of members in the currently-selected z-set.', + }) + total: number; +} diff --git a/redisinsight/api/src/modules/browser/services/browser-analytics/browser-analytics.service.spec.ts b/redisinsight/api/src/modules/browser/services/browser-analytics/browser-analytics.service.spec.ts new file mode 100644 index 0000000000..021a6073fc --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/browser-analytics/browser-analytics.service.spec.ts @@ -0,0 +1,435 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { mockStandaloneDatabaseEntity } from 'src/__mocks__'; +import { TelemetryEvents } from 'src/constants'; +import { RedisDataType } from 'src/modules/browser/dto'; +import { BrowserAnalyticsService } from './browser-analytics.service'; + +const instanceId = mockStandaloneDatabaseEntity.id; +const mockAddedEventProperties = '["foo"]["bar"]'; + +describe('BrowserAnalyticsService', () => { + let service: BrowserAnalyticsService; + let sendEventMethod; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + BrowserAnalyticsService, + ], + }).compile(); + + service = await module.get( + BrowserAnalyticsService, + ); + sendEventMethod = jest.spyOn( + service, + 'sendEvent', + ); + }); + + describe('sendKeysScannedEvent', () => { + it('should emit event without filters', () => { + service.sendKeysScannedEvent(instanceId, '*'); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeysScanned, + { + databaseId: instanceId, + }, + ); + }); + it('should emit event with filter by patter', () => { + service.sendKeysScannedEvent(instanceId, 'string*'); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeysScannedWithFilters, + { + databaseId: instanceId, + match: 'PATTERN', + }, + ); + }); + it('should emit event with filter by exact key name', () => { + service.sendKeysScannedEvent(instanceId, 'string'); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeysScannedWithFilters, + { + databaseId: instanceId, + match: 'EXACT_KEY_NAME', + }, + ); + }); + it('should emit event with filter by key type', () => { + service.sendKeysScannedEvent(instanceId, '*', RedisDataType.String); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeysScannedWithFilters, + { + databaseId: instanceId, + keyType: RedisDataType.String, + match: '*', + }, + ); + }); + it('should emit event with filter by key type and pattern', () => { + service.sendKeysScannedEvent( + instanceId, + 'string*', + RedisDataType.String, + { count: 200 }, + ); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeysScannedWithFilters, + { + databaseId: instanceId, + match: 'PATTERN', + keyType: RedisDataType.String, + count: 200, + }, + ); + }); + }); + + describe('sendKeyAddedEvent', () => { + it('should emit KeyAdded event', () => { + service.sendKeyAddedEvent(instanceId, RedisDataType.String); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyAdded, + { + databaseId: instanceId, + keyType: RedisDataType.String, + }, + ); + }); + it('should emit KeyAdded event with additional data', () => { + service.sendKeyAddedEvent(instanceId, RedisDataType.String, { TTL: -1 }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyAdded, + { + databaseId: instanceId, + keyType: RedisDataType.String, + TTL: -1, + }, + ); + }); + }); + + describe('sendKeyTTLChangedEvent', () => { + it('should emit KeyTTLChanged event', () => { + service.sendKeyTTLChangedEvent(instanceId, 200, -1); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyTTLChanged, + { + databaseId: instanceId, + TTL: 200, + previousTTL: -1, + }, + ); + }); + }); + + describe('sendKeysDeletedEvent', () => { + it('should emit KeyTTLChanged event', () => { + service.sendKeysDeletedEvent(instanceId, 10); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeysDeleted, + { + databaseId: instanceId, + numberOfDeletedKeys: 10, + }, + ); + }); + }); + + describe('sendKeyValueAddedEvent', () => { + it('should emit KeyValueAdded event', () => { + service.sendKeyValueAddedEvent(instanceId, RedisDataType.List); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueAdded, + { + databaseId: instanceId, + keyType: RedisDataType.List, + }, + ); + }); + it('should emit KeyValueAdded event with additional data', () => { + service.sendKeyValueAddedEvent(instanceId, RedisDataType.List, { + numberOfAdded: 1, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueAdded, + { + databaseId: instanceId, + numberOfAdded: 1, + keyType: RedisDataType.List, + }, + ); + }); + }); + + describe('sendKeyValueEditedEvent', () => { + it('should emit KeyValueEdited event', () => { + service.sendKeyValueEditedEvent(instanceId, RedisDataType.List); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueEdited, + { + databaseId: instanceId, + keyType: RedisDataType.List, + }, + ); + }); + it('should emit KeyValueEdited event with additional data', () => { + service.sendKeyValueEditedEvent(instanceId, RedisDataType.List, { + numberOfEdited: 1, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueEdited, + { + databaseId: instanceId, + keyType: RedisDataType.List, + numberOfEdited: 1, + }, + ); + }); + }); + + describe('sendKeyValueRemovedEvent', () => { + it('should emit KeyValueRemoved event', () => { + service.sendKeyValueRemovedEvent(instanceId, RedisDataType.List); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueRemoved, + { + databaseId: instanceId, + keyType: RedisDataType.List, + }, + ); + }); + it('should emit event KeyValueRemoved with additional data', () => { + service.sendKeyValueRemovedEvent(instanceId, RedisDataType.List, { + numberOfRemoved: 1, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueRemoved, + { + databaseId: instanceId, + keyType: RedisDataType.List, + numberOfRemoved: 1, + }, + ); + }); + }); + + describe('sendKeyScannedEvent', () => { + it('should emit KeyScanned event with filter by exact name', () => { + service.sendKeyScannedEvent(instanceId, RedisDataType.Hash, 'member'); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueFiltered, + { + databaseId: instanceId, + keyType: RedisDataType.Hash, + match: 'EXACT_VALUE_NAME', + }, + ); + }); + it('should emit KeyScanned event with filter by pattern', () => { + service.sendKeyScannedEvent(instanceId, RedisDataType.Hash, 'member*'); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueFiltered, + { + databaseId: instanceId, + keyType: RedisDataType.Hash, + match: 'PATTERN', + }, + ); + }); + it('should emit KeyScanned event with additional data', () => { + service.sendKeyScannedEvent(instanceId, RedisDataType.Hash, 'member*', { + length: 10, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueFiltered, + { + databaseId: instanceId, + keyType: RedisDataType.Hash, + match: 'PATTERN', + length: 10, + }, + ); + }); + it('should not emit event', () => { + service.sendKeyScannedEvent(instanceId, RedisDataType.Hash, '*'); + + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); + + describe('sendGetListElementByIndexEvent', () => { + it('should emit GetListElementByIndex event', () => { + service.sendGetListElementByIndexEvent(instanceId); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueFiltered, + { + databaseId: instanceId, + keyType: RedisDataType.List, + match: 'EXACT_VALUE_NAME', + }, + ); + }); + it('should emit GetListElementByIndex event with additional data', () => { + service.sendGetListElementByIndexEvent(instanceId, { + length: 10, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserKeyValueFiltered, + { + databaseId: instanceId, + keyType: RedisDataType.List, + match: 'EXACT_VALUE_NAME', + length: 10, + }, + ); + }); + }); + + describe('sendJsonPropertyAddedEvent', () => { + it('should emit JsonPropertyAdded event', () => { + service.sendJsonPropertyAddedEvent(instanceId, mockAddedEventProperties); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserJSONPropertyAdded, + { + databaseId: instanceId, + keyLevel: '1', + }, + ); + }); + it('should emit JsonPropertyAdded event with additional data', () => { + service.sendJsonPropertyAddedEvent(instanceId, mockAddedEventProperties, { + length: 10, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserJSONPropertyAdded, + { + databaseId: instanceId, + keyLevel: '1', + length: 10, + }, + ); + }); + }); + + describe('sendJsonPropertyEditedEvent', () => { + it('should emit JsonPropertyEdited event', () => { + service.sendJsonPropertyEditedEvent(instanceId, mockAddedEventProperties); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserJSONPropertyEdited, + { + databaseId: instanceId, + keyLevel: '1', + }, + ); + }); + it('should emit JsonPropertyEdited event with additional data', () => { + service.sendJsonPropertyEditedEvent(instanceId, mockAddedEventProperties, { + length: 10, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserJSONPropertyEdited, + { + databaseId: instanceId, + keyLevel: '1', + length: 10, + }, + ); + }); + }); + + describe('sendJsonPropertyDeletedEvent', () => { + it('should emit JsonPropertyDeleted event', () => { + service.sendJsonPropertyDeletedEvent(instanceId, mockAddedEventProperties); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserJSONPropertyDeleted, + { + databaseId: instanceId, + keyLevel: '1', + }, + ); + }); + it('should emit JsonPropertyDeleted event with additional data', () => { + service.sendJsonPropertyDeletedEvent(instanceId, mockAddedEventProperties, { + length: 10, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserJSONPropertyDeleted, + { + databaseId: instanceId, + keyLevel: '1', + length: 10, + }, + ); + }); + }); + + describe('sendJsonArrayPropertyAppendEven', () => { + it('should emit JsonArrayPropertyAppend event on append element to root', () => { + service.sendJsonArrayPropertyAppendEvent(instanceId, '.'); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserJSONPropertyAdded, + { + databaseId: instanceId, + keyLevel: '0', + }, + ); + }); + it('should emit JsonArrayPropertyAppend event on append element to key at deep level', () => { + service.sendJsonArrayPropertyAppendEvent(instanceId, mockAddedEventProperties); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserJSONPropertyAdded, + { + databaseId: instanceId, + keyLevel: '2', + }, + ); + }); + it('should emit JsonArrayPropertyAppend event with additional data', () => { + service.sendJsonArrayPropertyAppendEvent(instanceId, mockAddedEventProperties, { + length: 10, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.BrowserJSONPropertyAdded, + { + databaseId: instanceId, + keyLevel: '2', + length: 10, + }, + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/browser-analytics/browser-analytics.service.ts b/redisinsight/api/src/modules/browser/services/browser-analytics/browser-analytics.service.ts new file mode 100644 index 0000000000..135a24157f --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/browser-analytics/browser-analytics.service.ts @@ -0,0 +1,253 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import * as isGlob from 'is-glob'; +import { TelemetryEvents } from 'src/constants'; +import { RedisDataType } from 'src/modules/browser/dto'; +import { getJsonPathLevel } from 'src/utils'; +import { TelemetryBaseService } from 'src/modules/shared/services/base/telemetry.base.service'; + +@Injectable() +export class BrowserAnalyticsService extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendKeysScannedEvent( + instanceId: string, + match: string = '*', + keyType?: RedisDataType, + additionalData?: object, + ): void { + try { + if (match !== '*' || keyType) { + let matchValue = '*'; + if (match !== '*') { + matchValue = !isGlob(match, { strict: false }) + ? 'EXACT_KEY_NAME' + : 'PATTERN'; + } + this.sendEvent( + TelemetryEvents.BrowserKeysScannedWithFilters, + { + databaseId: instanceId, + match: matchValue, + keyType, + ...additionalData, + }, + ); + } else { + this.sendEvent( + TelemetryEvents.BrowserKeysScanned, + { + databaseId: instanceId, + ...additionalData, + }, + ); + } + } catch (e) { + // continue regardless of error + } + } + + sendKeyAddedEvent( + instanceId: string, + keyType: RedisDataType, + additionalData: object = {}, + ): void { + this.sendEvent( + TelemetryEvents.BrowserKeyAdded, + { + databaseId: instanceId, + keyType, + ...additionalData, + }, + ); + } + + sendKeyTTLChangedEvent( + instanceId: string, + TTL: number, + previousTTL: number, + ): void { + this.sendEvent( + TelemetryEvents.BrowserKeyTTLChanged, + { + databaseId: instanceId, + TTL, + previousTTL, + }, + ); + } + + sendKeysDeletedEvent(instanceId: string, numberOfDeletedKeys: number): void { + this.sendEvent( + TelemetryEvents.BrowserKeysDeleted, + { + databaseId: instanceId, + numberOfDeletedKeys, + }, + ); + } + + sendKeyValueAddedEvent( + instanceId: string, + keyType: RedisDataType, + additionalData: object = {}, + ): void { + this.sendEvent( + TelemetryEvents.BrowserKeyValueAdded, + { + databaseId: instanceId, + keyType, + ...additionalData, + }, + ); + } + + sendKeyValueEditedEvent( + instanceId: string, + keyType: RedisDataType, + additionalData: object = {}, + ): void { + this.sendEvent( + TelemetryEvents.BrowserKeyValueEdited, + { + databaseId: instanceId, + keyType, + ...additionalData, + }, + ); + } + + sendKeyValueRemovedEvent( + instanceId: string, + keyType: RedisDataType, + additionalData: object = {}, + ): void { + this.sendEvent( + TelemetryEvents.BrowserKeyValueRemoved, + { + databaseId: instanceId, + keyType, + ...additionalData, + }, + ); + } + + sendKeyScannedEvent( + instanceId: string, + keyType: RedisDataType, + match: string = '*', + additionalData: object = {}, + ): void { + try { + if (match !== '*') { + const matchValue = !isGlob(match, { strict: false }) + ? 'EXACT_VALUE_NAME' + : 'PATTERN'; + this.sendEvent( + TelemetryEvents.BrowserKeyValueFiltered, + { + databaseId: instanceId, + keyType, + match: matchValue, + ...additionalData, + }, + ); + } + } catch (e) { + // continue regardless of error + } + } + + sendGetListElementByIndexEvent(instanceId: string, additionalData: object = {}): void { + this.sendEvent( + TelemetryEvents.BrowserKeyValueFiltered, + { + databaseId: instanceId, + keyType: RedisDataType.List, + match: 'EXACT_VALUE_NAME', + ...additionalData, + }, + ); + } + + sendJsonPropertyAddedEvent( + instanceId: string, + path: string, + additionalData: object = {}, + ): void { + try { + this.sendEvent( + TelemetryEvents.BrowserJSONPropertyAdded, + { + databaseId: instanceId, + keyLevel: getJsonPathLevel(path), + ...additionalData, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendJsonArrayPropertyAppendEvent( + instanceId: string, + path: string, + additionalData: object = {}, + ): void { + try { + // An array element is appended using the path of the parent key. + // And we need to increase the keyLevel by one to get it for child element. + const keyLevel = path === '.' ? '0' : getJsonPathLevel(`${path}[0]`); + this.sendEvent( + TelemetryEvents.BrowserJSONPropertyAdded, + { + databaseId: instanceId, + keyLevel, + ...additionalData, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendJsonPropertyEditedEvent( + instanceId: string, + path: string, + additionalData: object = {}, + ): void { + try { + this.sendEvent( + TelemetryEvents.BrowserJSONPropertyEdited, + { + databaseId: instanceId, + keyLevel: getJsonPathLevel(path), + ...additionalData, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendJsonPropertyDeletedEvent( + instanceId: string, + path: string, + additionalData: object = {}, + ): void { + try { + this.sendEvent( + TelemetryEvents.BrowserJSONPropertyDeleted, + { + databaseId: instanceId, + keyLevel: getJsonPathLevel(path), + ...additionalData, + }, + ); + } catch (e) { + // continue regardless of error + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts new file mode 100644 index 0000000000..530d08f163 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts @@ -0,0 +1,235 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import * as Redis from 'ioredis-mock'; +import { mockStandaloneDatabaseEntity } from 'src/__mocks__'; +import { + IFindRedisClientInstanceByOptions, + RedisService, +} from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { + BrowserToolCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { InternalServerErrorException } from '@nestjs/common'; +import { + BrowserToolClusterService, +} from 'src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service'; +import { EndpointDto } from 'src/modules/instances/dto/database-instance.dto'; +import { ClusterNodeNotFoundError } from 'src/modules/cli/constants/errors'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockClient = new Redis(); +const mockCluster = new Redis.Cluster([]); +const mockClusterNode1 = new Redis(); +const mockClusterNode2 = new Redis(); +mockClusterNode1.send_command = jest.fn(); +mockClusterNode2.send_command = jest.fn(); +mockClusterNode1.options = { host: '127.0.0.1', port: 7001 }; +mockClusterNode2.options = { host: '127.0.0.1', port: 7002 }; +const mockConnectionErrorMessage = 'Could not connect to localhost, please check the connection details.'; + +describe('BrowserToolClusterService', () => { + let service: BrowserToolClusterService; + let getRedisClient; + let execPipelineFromClient; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BrowserToolClusterService, + { + provide: RedisService, + useFactory: () => ({}), + }, + { + provide: InstancesBusinessService, + useFactory: () => ({}), + }, + ], + }).compile(); + + service = await module.get( + BrowserToolClusterService, + ); + getRedisClient = jest.spyOn( + service, + 'getRedisClient', + ); + execPipelineFromClient = jest.spyOn( + service, + 'execPipelineFromClient', + ); + mockClient.send_command = jest.fn(); + }); + + describe('execCommand', () => { + const keyName = 'keyName'; + it('should call send_command with correct args', async () => { + getRedisClient.mockResolvedValue(mockClient); + + await service.execCommand( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + ); + + expect(mockClient.send_command).toHaveBeenCalledWith('memory', [ + 'usage', + keyName, + ]); + }); + it('should throw error for execCommand', async () => { + const error = new InternalServerErrorException( + mockConnectionErrorMessage, + ); + getRedisClient.mockRejectedValue(error); + + await expect( + service.execCommand( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + ), + ).rejects.toThrow(InternalServerErrorException); + expect(mockClient.send_command).not.toHaveBeenCalled(); + }); + }); + + describe('execPipeline', () => { + const keyName = 'keyName'; + const args: Array< + [toolCommand: BrowserToolCommands, ...args: Array] + > = [ + [BrowserToolKeysCommands.Type, keyName], + [BrowserToolKeysCommands.Ttl, keyName], + ]; + it('should call execPipelineFromClient with correct args', async () => { + getRedisClient.mockResolvedValue(mockClient); + execPipelineFromClient.mockResolvedValue(); + + await service.execPipeline(mockClientOptions, args); + + expect(execPipelineFromClient).toHaveBeenCalledWith(mockClient, args); + }); + it('should throw error for execPipeline', async () => { + const error = new InternalServerErrorException( + mockConnectionErrorMessage, + ); + getRedisClient.mockRejectedValue(error); + + await expect( + service.execPipeline(mockClientOptions, args), + ).rejects.toThrow(InternalServerErrorException); + expect(execPipelineFromClient).not.toHaveBeenCalled(); + }); + }); + + describe('execCommandFromNodes', () => { + mockCluster.nodes = jest.fn(); + const keyName = 'keyName'; + + it('should execute command for all nodes', async () => { + getRedisClient.mockResolvedValue(mockCluster); + mockClusterNode1.send_command.mockResolvedValue(70); + mockClusterNode2.send_command.mockResolvedValue(10); + mockCluster.nodes.mockReturnValue([mockClusterNode1, mockClusterNode2]); + + const result = await service.execCommandFromNodes( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + 'all', + ); + + expect(result).toEqual([ + { result: 70, ...mockClusterNode1.options }, + { result: 10, ...mockClusterNode2.options }, + ]); + expect(mockClusterNode1.send_command).toHaveBeenCalledWith('memory', [ + 'usage', + keyName, + ]); + expect(mockClusterNode2.send_command).toHaveBeenCalledWith('memory', [ + 'usage', + keyName, + ]); + }); + it('should throw error for execCommandFromNodes', async () => { + const error = new InternalServerErrorException( + 'Could not connect to localhost, please check the connection details.', + ); + getRedisClient.mockRejectedValue(error); + + await expect( + service.execCommandFromNodes( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + 'all', + ), + ).rejects.toThrow(InternalServerErrorException); + }); + }); + + describe('execCommandFromNode', () => { + mockCluster.nodes = jest.fn(); + const keyName = 'keyName'; + + it('should execute command from node', async () => { + getRedisClient.mockResolvedValue(mockCluster); + mockClusterNode1.send_command.mockResolvedValue(70); + mockCluster.nodes.mockReturnValue([mockClusterNode1, mockClusterNode2]); + + const result = await service.execCommandFromNode( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + { ...mockClusterNode1.options }, + ); + + expect(result).toEqual({ result: 70, ...mockClusterNode1.options }); + expect(mockClusterNode1.send_command).toHaveBeenCalledWith('memory', [ + 'usage', + keyName, + ]); + }); + it('should throw error that cluster node not found', async () => { + const nodeOptions: EndpointDto = { host: '127.0.0.1', port: 7003 }; + const error = new ClusterNodeNotFoundError( + ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND( + `${nodeOptions.host}:${nodeOptions.port}`, + ), + ); + getRedisClient.mockResolvedValue(mockCluster); + mockCluster.nodes.mockReturnValue([mockClusterNode1, mockClusterNode2]); + + await expect( + service.execCommandFromNode( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + nodeOptions, + ), + ).rejects.toThrow(error); + }); + it('should throw error for execCommandFromNode', async () => { + const error = new InternalServerErrorException( + mockConnectionErrorMessage, + ); + getRedisClient.mockRejectedValue(error); + + await expect( + service.execCommandFromNode( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + { ...mockClusterNode1.options }, + ), + ).rejects.toThrow(InternalServerErrorException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts new file mode 100644 index 0000000000..ec0ef4b8b1 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts @@ -0,0 +1,133 @@ +import { Injectable, Logger } from '@nestjs/common'; +import IORedis, { NodeRole, Redis } from 'ioredis'; +import { AppTool } from 'src/models'; +import { RedisConsumerAbstractService } from 'src/modules/shared/services/base/redis-consumer.abstract.service'; +import { + IFindRedisClientInstanceByOptions, + RedisService, +} from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { EndpointDto } from 'src/modules/instances/dto/database-instance.dto'; +import { BrowserToolCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { ClusterNodeNotFoundError } from 'src/modules/cli/constants/errors'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { getRedisPipelineSummary } from 'src/utils/cli-helper'; +import { getConnectionName } from 'src/utils/redis-connection-helper'; + +export interface IExecCommandFromClusterNode { + host: string; + port: number; + result: any; +} + +@Injectable() +export class BrowserToolClusterService extends RedisConsumerAbstractService { + private logger = new Logger('BrowserToolClusterService'); + + constructor( + protected redisService: RedisService, + protected instancesBusinessService: InstancesBusinessService, + ) { + super(AppTool.Browser, redisService, instancesBusinessService); + } + + async execCommand( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommand: BrowserToolCommands, + args: Array, + ): Promise { + const client = await this.getRedisClient(clientOptions); + this.logger.log(`Execute command '${toolCommand}', connectionName: ${getConnectionName(client)}`); + const [command, ...commandArgs] = toolCommand.split(' '); + // TODO: use sendCommand method + return client.send_command(command, [...commandArgs, ...args]); + } + + async execPipeline( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommands: Array< + [toolCommand: BrowserToolCommands, ...args: Array] + >, + ): Promise { + const client = await this.getRedisClient(clientOptions); + const pipelineSummery = getRedisPipelineSummary(toolCommands); + this.logger.log( + `Execute pipeline ${pipelineSummery.summary}, length: ${pipelineSummery.length}, connectionName: ${getConnectionName(client)}`, + ); + return this.execPipelineFromClient(client, toolCommands); + } + + async execCommandFromNodes( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommand: BrowserToolCommands, + args: Array, + nodeRole: NodeRole = 'all', + ): Promise { + + const client = await this.getRedisClient(clientOptions); + const nodes: Redis[] = client.nodes(nodeRole); + this.logger.log(`Execute command '${toolCommand}' from nodes, connectionName: ${getConnectionName(client)}`); + return await Promise.all( + nodes.map( + async (node: IORedis.Redis): Promise => { + const { host, port } = node.options; + const [command, ...commandArgs] = toolCommand.split(' '); + const result = await node.send_command(command, [ + ...commandArgs, + ...args, + ]); + return { + result, + host, + port, + }; + }, + ), + ); + } + + async execCommandFromNode( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommand: BrowserToolCommands, + args: Array, + exactNode: EndpointDto, + ): Promise { + const client = await this.getRedisClient(clientOptions); + this.logger.log(`Execute command '${toolCommand}' from node, connectionName: ${getConnectionName(client)}`); + + const [command, ...commandArgs] = toolCommand.split(' '); + const { host, port } = exactNode; + const allClusterNodes: Redis[] = client.nodes('all'); + const node = allClusterNodes.find((item) => { + const { options } = item; + return options?.host === host && options.port === port; + }); + if (!node) { + this.logger.error( + `Cluster node not found. ${JSON.stringify(exactNode)}`, + ); + throw new ClusterNodeNotFoundError( + ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND( + `${exactNode.host}:${exactNode.port}`, + ), + ); + } + const result = await node.send_command(command, [ + ...commandArgs, + ...args, + ]); + return { + host, + port, + result, + }; + } + + async getNodes( + clientOptions: IFindRedisClientInstanceByOptions, + nodeRole: NodeRole = 'all', + ) { + const client = await this.getRedisClient(clientOptions); + return client.nodes(nodeRole); + } +} diff --git a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts new file mode 100644 index 0000000000..229e91f4e7 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts @@ -0,0 +1,151 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import * as Redis from 'ioredis-mock'; +import { mockStandaloneDatabaseEntity } from 'src/__mocks__'; +import { + IFindRedisClientInstanceByOptions, + RedisService, +} from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolCommands, + BrowserToolKeysCommands, + BrowserToolStringCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { InternalServerErrorException } from '@nestjs/common'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockClient = new Redis(); +const mockConnectionErrorMessage = 'Could not connect to localhost, please check the connection details.'; + +describe('BrowserToolService', () => { + let service: BrowserToolService; + let getRedisClient; + let execPipelineFromClient; + let execMultiFromClient; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BrowserToolService, + { + provide: RedisService, + useFactory: () => ({}), + }, + { + provide: InstancesBusinessService, + useFactory: () => ({}), + }, + ], + }).compile(); + + service = await module.get(BrowserToolService); + getRedisClient = jest.spyOn( + service, + 'getRedisClient', + ); + execPipelineFromClient = jest.spyOn( + service, + 'execPipelineFromClient', + ); + execMultiFromClient = jest.spyOn( + service, + 'execMultiFromClient', + ); + mockClient.send_command = jest.fn(); + }); + + describe('execCommand', () => { + const keyName = 'keyName'; + it('should call send_command with correct args', async () => { + getRedisClient.mockResolvedValue(mockClient); + + await service.execCommand( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + ); + + expect(mockClient.send_command).toHaveBeenCalledWith('memory', [ + 'usage', + keyName, + ]); + }); + it('should throw error for execCommand', async () => { + const error = new InternalServerErrorException( + mockConnectionErrorMessage, + ); + getRedisClient.mockRejectedValue(error); + + await expect( + service.execCommand( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + ), + ).rejects.toThrow(InternalServerErrorException); + expect(mockClient.send_command).not.toHaveBeenCalled(); + }); + }); + + describe('execPipeline', () => { + const keyName = 'keyName'; + const args: Array< + [toolCommand: BrowserToolCommands, ...args: Array] + > = [ + [BrowserToolKeysCommands.Type, keyName], + [BrowserToolKeysCommands.Ttl, keyName], + ]; + it('should call execPipelineFromClient with correct args', async () => { + getRedisClient.mockResolvedValue(mockClient); + execPipelineFromClient.mockResolvedValue(); + + await service.execPipeline(mockClientOptions, args); + + expect(execPipelineFromClient).toHaveBeenCalledWith(mockClient, args); + }); + it('should throw error', async () => { + const error = new InternalServerErrorException( + mockConnectionErrorMessage, + ); + getRedisClient.mockRejectedValue(error); + + await expect( + service.execPipeline(mockClientOptions, args), + ).rejects.toThrow(InternalServerErrorException); + expect(execPipelineFromClient).not.toHaveBeenCalled(); + }); + }); + + describe('execMulti', () => { + const keyName = 'keyName'; + const args: Array< + [toolCommand: BrowserToolCommands, ...args: Array] + > = [ + [BrowserToolStringCommands.Set, keyName], + [BrowserToolStringCommands.Get, keyName], + ]; + it('should call execMultiFromClient with correct args', async () => { + getRedisClient.mockResolvedValue(mockClient); + execPipelineFromClient.mockResolvedValue(); + + await service.execMulti(mockClientOptions, args); + + expect(execMultiFromClient).toHaveBeenCalledWith(mockClient, args); + }); + it('should throw error', async () => { + const error = new InternalServerErrorException( + mockConnectionErrorMessage, + ); + getRedisClient.mockRejectedValue(error); + + await expect(service.execMulti(mockClientOptions, args)).rejects.toThrow( + InternalServerErrorException, + ); + expect(execMultiFromClient).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts new file mode 100644 index 0000000000..a661c2b32c --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AppTool, ReplyError } from 'src/models'; +import { + IFindRedisClientInstanceByOptions, + RedisService, +} from 'src/modules/core/services/redis/redis.service'; +import { RedisConsumerAbstractService } from 'src/modules/shared/services/base/redis-consumer.abstract.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { BrowserToolCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { getRedisPipelineSummary } from 'src/utils/cli-helper'; +import { getConnectionName } from 'src/utils/redis-connection-helper'; + +@Injectable() +export class BrowserToolService extends RedisConsumerAbstractService { + private logger = new Logger('BrowserToolService'); + + constructor( + protected redisService: RedisService, + protected instancesBusinessService: InstancesBusinessService, + ) { + super(AppTool.Browser, redisService, instancesBusinessService); + } + + async execCommand( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommand: BrowserToolCommands, + args: Array, + ): Promise { + const client = await this.getRedisClient(clientOptions); + this.logger.log(`Execute command '${toolCommand}', connectionName: ${getConnectionName(client)}`); + const [command, ...commandArgs] = toolCommand.split(' '); + // TODO: use sendCommand method + return client.send_command(command, [...commandArgs, ...args]); + } + + async execPipeline( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommands: Array<[toolCommand: BrowserToolCommands, ...args: Array]>, + ): Promise<[ReplyError | null, any]> { + const client = await this.getRedisClient(clientOptions); + const pipelineSummery = getRedisPipelineSummary(toolCommands); + this.logger.log( + `Execute pipeline ${pipelineSummery.summary}, length: ${pipelineSummery.length}, connectionName: ${getConnectionName(client)}`, + ); + return this.execPipelineFromClient(client, toolCommands); + } + + async execMulti( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommands: Array<[toolCommand: BrowserToolCommands, ...args: Array]>, + ): Promise<[ReplyError | null, any]> { + const client = await this.getRedisClient(clientOptions); + const pipelineSummery = getRedisPipelineSummary(toolCommands); + this.logger.log( + `Execute pipeline ${pipelineSummery.summary}, length: ${pipelineSummery.length}, connectionName: ${getConnectionName(client)}`, + ); + return this.execMultiFromClient(client, toolCommands); + } +} diff --git a/redisinsight/api/src/modules/browser/services/hash-business/hash-business.service.spec.ts b/redisinsight/api/src/modules/browser/services/hash-business/hash-business.service.spec.ts new file mode 100644 index 0000000000..e7599f39f4 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/hash-business/hash-business.service.spec.ts @@ -0,0 +1,472 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { when } from 'jest-when'; +import { flatMap } from 'lodash'; +import { ReplyError } from 'src/models/redis-client'; +import { + mockBrowserAnalyticsService, + mockRedisConsumer, + mockRedisNoPermError, + mockRedisWrongTypeError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import config from 'src/utils/config'; +import { + AddFieldsToHashDto, + DeleteFieldsFromHashDto, + GetHashFieldsDto, + GetHashFieldsResponse, + HashFieldDto, +} from 'src/modules/browser/dto/hash.dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolHashCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { HashBusinessService } from './hash-business.service'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockAddFieldsDto: AddFieldsToHashDto = { + keyName: 'testHash', + fields: [ + { + field: 'field1', + value: 'value', + }, + ], +}; + +const mockDeleteFieldsDto: DeleteFieldsFromHashDto = { + keyName: mockAddFieldsDto.keyName, + fields: mockAddFieldsDto.fields.map((item) => item.field), +}; + +const mockGetFieldsDto: GetHashFieldsDto = { + keyName: mockAddFieldsDto.keyName, + cursor: 0, + count: REDIS_SCAN_CONFIG.countDefault || 15, + match: '*', +}; + +const mockGetFieldsResponse: GetHashFieldsResponse = { + keyName: mockGetFieldsDto.keyName, + nextCursor: 0, + total: mockAddFieldsDto.fields.length, + fields: mockAddFieldsDto.fields, +}; + +const mockRedisHScanResponse = [ + 0, + flatMap(mockAddFieldsDto.fields, ({ field, value }: HashFieldDto) => [field, value]), +]; + +describe('HashBusinessService', () => { + let service: HashBusinessService; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + HashBusinessService, + { + provide: BrowserAnalyticsService, + useFactory: mockBrowserAnalyticsService, + }, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(HashBusinessService); + browserTool = module.get(BrowserToolService); + }); + + describe('createHash', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddFieldsDto.keyName, + ]) + .mockResolvedValue(false); + }); + it('create hash with expiration', async () => { + service.createHashWithExpiration = jest + .fn() + .mockResolvedValue(undefined); + const { keyName, fields } = mockAddFieldsDto; + const expire = 1000; + const commandArgs = flatMap(fields, ({ field, value }: HashFieldDto) => [field, value]); + + await expect( + service.createHash(mockClientOptions, { ...mockAddFieldsDto, expire }), + ).resolves.not.toThrow(); + expect(service.createHashWithExpiration).toHaveBeenCalledWith( + mockClientOptions, + keyName, + commandArgs, + expire, + ); + }); + it('create hash without expiration', async () => { + service.createHashWithExpiration = jest.fn(); + const { keyName, fields } = mockAddFieldsDto; + const commandArgs = flatMap(fields, ({ field, value }: HashFieldDto) => [field, value]); + + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolHashCommands.HSet, [ + keyName, + ...commandArgs, + ]) + .mockResolvedValue(1); + + await expect( + service.createHash(mockClientOptions, mockAddFieldsDto), + ).resolves.not.toThrow(); + expect(service.createHashWithExpiration).not.toHaveBeenCalled(); + }); + it('key with this name exist', async () => { + const { keyName, fields } = mockAddFieldsDto; + const args = flatMap(fields, ({ field, value }: HashFieldDto) => [field, value]); + + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + keyName, + ]) + .mockResolvedValue(true); + + await expect( + service.createHash(mockClientOptions, mockAddFieldsDto), + ).rejects.toThrow(ConflictException); + expect( + browserTool.execCommand, + ).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolHashCommands.HSet, + [keyName, ...args], + ); + }); + it("user don't have required permissions for createHash", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'HSET', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.createHash(mockClientOptions, mockAddFieldsDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getFields', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolHashCommands.HLen, [ + mockAddFieldsDto.keyName, + ]) + .mockResolvedValue(mockAddFieldsDto.fields.length); + }); + it('succeed to get fields of the hash', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolHashCommands.HScan, + expect.anything(), + ) + .mockResolvedValue(mockRedisHScanResponse); + + const result = await service.getFields( + mockClientOptions, + mockGetFieldsDto, + ); + expect(result).toEqual(mockGetFieldsResponse); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolHashCommands.HScan, + expect.anything(), + ); + }); + it('succeed to find exact field in the hash', async () => { + const item = mockAddFieldsDto.fields[0]; + const dto: GetHashFieldsDto = { + ...mockGetFieldsDto, + match: item.field, + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolHashCommands.HGet, [ + dto.keyName, + dto.match, + ]) + .mockResolvedValue(item.value); + + const result = await service.getFields(mockClientOptions, dto); + + expect(result).toEqual(mockGetFieldsResponse); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolHashCommands.HScan, + expect.anything(), + ); + }); + it('failed to find exact field in the hash', async () => { + const dto: GetHashFieldsDto = { + ...mockGetFieldsDto, + match: 'field', + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolHashCommands.HGet, [ + dto.keyName, + dto.match, + ]) + .mockResolvedValue(null); + + const result = await service.getFields(mockClientOptions, dto); + + expect(result).toEqual({ ...mockGetFieldsResponse, fields: [] }); + }); + it('should not call scan when math contains escaped glob', async () => { + const item = { + field: 'fi[a-e]ld', + value: 'value', + }; + const dto: GetHashFieldsDto = { + ...mockGetFieldsDto, + match: 'fi\\[a-e\\]ld', + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolHashCommands.HGet, [ + dto.keyName, + item.field, + ]) + .mockResolvedValue('value'); + + const result = await service.getFields(mockClientOptions, dto); + + expect(result).toEqual({ ...mockGetFieldsResponse, fields: [item] }); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolHashCommands.HScan, + expect.anything(), + ); + }); + // TODO: uncomment after enabling threshold for hash scan + // it('should stop hash full scan', async () => { + // const dto: GetHashFieldsDto = { + // ...mockGetFieldsDto, + // count: REDIS_SCAN_CONFIG.countDefault, + // match: '*un-exist-field*', + // }; + // const maxScanCalls = Math.round( + // REDIS_SCAN_CONFIG.countThreshold / REDIS_SCAN_CONFIG.countDefault, + // ); + // when(browserTool.execCommand) + // .calledWith( + // mockClientOptions, + // BrowserToolHashCommands.HScan, + // expect.anything(), + // ) + // .mockResolvedValue(['200', []]); + // + // await service.getFields(mockClientOptions, dto); + // + // expect(browserTool.execCommand).toHaveBeenCalledTimes(maxScanCalls + 1); + // }); + it('key with this name does not exist for getFields', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolHashCommands.HLen, [ + mockGetFieldsDto.keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.getFields(mockClientOptions, mockGetFieldsDto), + ).rejects.toThrow(NotFoundException); + }); + it("try to use 'HLEN' command not for hash data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'HLEN', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.getFields(mockClientOptions, mockGetFieldsDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for getFields", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'HLEN', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.getFields(mockClientOptions, mockGetFieldsDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('addFields', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddFieldsDto.keyName, + ]) + .mockResolvedValue(true); + }); + it('succeed to add/update fields to the Hash data type', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolHashCommands.HSet, + expect.anything(), + ) + .mockResolvedValue(1); + const { keyName, fields } = mockAddFieldsDto; + const commandArgs = flatMap(fields, ({ field, value }: HashFieldDto) => [field, value]); + + await expect( + service.addFields(mockClientOptions, mockAddFieldsDto), + ).resolves.not.toThrow(); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolHashCommands.HSet, + [keyName, ...commandArgs], + ); + }); + it('key with this name does not exist for addFields', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddFieldsDto.keyName, + ]) + .mockResolvedValue(false); + + await expect( + service.addFields(mockClientOptions, mockAddFieldsDto), + ).rejects.toThrow(NotFoundException); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolHashCommands.HSet, + expect.anything(), + ); + }); + it("try to use 'HSET' command not for hash data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'HSET', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolHashCommands.HSet, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.addFields(mockClientOptions, mockAddFieldsDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for addFields", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'HSET', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolHashCommands.HSet, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.addFields(mockClientOptions, mockAddFieldsDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('deleteFields', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockDeleteFieldsDto.keyName, + ]) + .mockResolvedValue(true); + }); + it('succeeded to delete fields from Hash data type', async () => { + const { fields } = mockDeleteFieldsDto; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolHashCommands.HDel, + expect.anything(), + ) + .mockResolvedValue(fields.length); + + const result = await service.deleteFields( + mockClientOptions, + mockDeleteFieldsDto, + ); + + expect(result).toEqual({ affected: fields.length }); + }); + it('key with this name does not exist for deleteFields', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockDeleteFieldsDto.keyName, + ]) + .mockResolvedValue(false); + + await expect( + service.deleteFields(mockClientOptions, mockDeleteFieldsDto), + ).rejects.toThrow(NotFoundException); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolHashCommands.HDel, + expect.anything(), + ); + }); + it("try to use 'HDEL' command not for Hash data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'HDEL', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.deleteFields(mockClientOptions, mockDeleteFieldsDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for deleteFields", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'HDEL', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.deleteFields(mockClientOptions, mockDeleteFieldsDto), + ).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/hash-business/hash-business.service.ts b/redisinsight/api/src/modules/browser/services/hash-business/hash-business.service.ts new file mode 100644 index 0000000000..0758eec4dd --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/hash-business/hash-business.service.ts @@ -0,0 +1,315 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { chunk, flatMap, isNull } from 'lodash'; +import * as isGlob from 'is-glob'; +import { catchAclError, catchTransactionError, unescapeGlob } from 'src/utils'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { RedisErrorCodes } from 'src/constants'; +import config from 'src/utils/config'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolHashCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { + AddFieldsToHashDto, + CreateHashWithExpireDto, + DeleteFieldsFromHashDto, + DeleteFieldsFromHashResponse, + GetHashFieldsDto, + GetHashFieldsResponse, + HashFieldDto, + HashScanResponse, +} from '../../dto/hash.dto'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +@Injectable() +export class HashBusinessService { + private logger = new Logger('hashBusinessService'); + + constructor( + private browserTool: BrowserToolService, + private browserAnalyticsService: BrowserAnalyticsService, + ) {} + + public async createHash( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateHashWithExpireDto, + ): Promise { + this.logger.log('Creating Hash data type.'); + const { keyName, fields } = dto; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (isExist) { + this.logger.error( + `Failed to create Hash data type. ${ERROR_MESSAGES.KEY_NAME_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new ConflictException(ERROR_MESSAGES.KEY_NAME_EXIST), + ); + } + const args = flatMap(fields, ({ field, value }: HashFieldDto) => [field, value]); + if (dto.expire) { + await this.createHashWithExpiration( + clientOptions, + keyName, + args, + dto.expire, + ); + } else { + await this.createSimpleHash(clientOptions, keyName, args); + } + this.browserAnalyticsService.sendKeyAddedEvent( + clientOptions.instanceId, + RedisDataType.Hash, + { + length: fields.length, + TTL: dto.expire || -1, + }, + ); + this.logger.log('Succeed to create Hash data type.'); + } catch (error) { + this.logger.error('Failed to create Hash data type.', error); + catchAclError(error); + } + return null; + } + + public async getFields( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetHashFieldsDto, + ): Promise { + this.logger.log('Getting fields of the Hash data type stored at key.'); + const { keyName } = dto; + let result: GetHashFieldsResponse = { + keyName, + total: 0, + fields: [], + nextCursor: dto.cursor, + }; + try { + result.total = await this.browserTool.execCommand( + clientOptions, + BrowserToolHashCommands.HLen, + [keyName], + ); + if (!result.total) { + this.logger.error( + `Failed to get fields of the Hash data type. Not Found key: ${keyName}.`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + if (dto.match && !isGlob(dto.match, { strict: false })) { + const field = unescapeGlob(dto.match); + result.nextCursor = 0; + const value = await this.browserTool.execCommand( + clientOptions, + BrowserToolHashCommands.HGet, + [keyName, field], + ); + if (!isNull(value)) { + result.fields.push({ field, value }); + } + } else { + const scanResult = await this.scanHash(clientOptions, dto); + result = { ...result, ...scanResult }; + } + this.browserAnalyticsService.sendKeyScannedEvent( + clientOptions.instanceId, + RedisDataType.Hash, + dto.match, + { + length: result.total, + }, + ); + this.logger.log('Succeed to get fields of the Hash data type.'); + return result; + } catch (error) { + this.logger.error('Failed to get fields of the Hash data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async addFields( + clientOptions: IFindRedisClientInstanceByOptions, + dto: AddFieldsToHashDto, + ): Promise { + this.logger.log('Adding fields to the Hash data type.'); + const { keyName, fields } = dto; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to add fields to Hash data type. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + const args = flatMap(fields, ({ field, value }: HashFieldDto) => [field, value]); + const added = await this.browserTool.execCommand( + clientOptions, + BrowserToolHashCommands.HSet, + [keyName, ...args], + ); + if (added) { + this.browserAnalyticsService.sendKeyValueAddedEvent( + clientOptions.instanceId, + RedisDataType.Hash, + { + numberOfAdded: added, + }, + ); + } + if (fields.length - added > 0) { + this.browserAnalyticsService.sendKeyValueEditedEvent( + clientOptions.instanceId, + RedisDataType.Hash, + ); + } + this.logger.log('Succeed to add fields to Hash data type.'); + } catch (error) { + this.logger.error('Failed to add fields to Hash data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + return null; + } + + public async deleteFields( + clientOptions: IFindRedisClientInstanceByOptions, + dto: DeleteFieldsFromHashDto, + ): Promise { + this.logger.log('Deleting fields from the Hash data type.'); + const { keyName, fields } = dto; + let result; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to delete fields from the Hash data type. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolHashCommands.HDel, + [keyName, ...fields], + ); + } catch (error) { + this.logger.error('Failed to delete fields from the Hash data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + if (result) { + this.browserAnalyticsService.sendKeyValueRemovedEvent( + clientOptions.instanceId, + RedisDataType.Hash, + { + numberOfRemoved: result, + }, + ); + } + this.logger.log('Succeed to delete fields from the Hash data type.'); + return { affected: result }; + } + + public async createSimpleHash( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + args: string[], + ): Promise { + await this.browserTool.execCommand( + clientOptions, + BrowserToolHashCommands.HSet, + [key, ...args], + ); + } + + public async createHashWithExpiration( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + args: string[], + expire, + ): Promise { + const [ + transactionError, + transactionResults, + ] = await this.browserTool.execMulti(clientOptions, [ + [BrowserToolHashCommands.HSet, key, ...args], + [BrowserToolKeysCommands.Expire, key, expire], + ]); + catchTransactionError(transactionError, transactionResults); + } + + public async scanHash( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetHashFieldsDto, + ): Promise { + const { keyName } = dto; + const count = dto.count || REDIS_SCAN_CONFIG.countDefault; + const match = dto.match !== undefined ? dto.match : '*'; + let result: HashScanResponse = { + keyName, + nextCursor: null, + fields: [], + }; + while (result.nextCursor !== 0 && result.fields.length < count) { + const scanResult = await this.browserTool.execCommand( + clientOptions, + BrowserToolHashCommands.HScan, + [ + keyName, + `${result.nextCursor || dto.cursor}`, + 'MATCH', + match, + 'COUNT', + count, + ], + ); + const [nextCursor, fieldsArray] = scanResult; + const fields: HashFieldDto[] = chunk( + fieldsArray, + 2, + ).map(([field, value]: string[]) => ({ field, value })); + result = { + ...result, + nextCursor: parseInt(nextCursor, 10), + fields: [...result.fields, ...fields], + }; + } + return result; + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.interface.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.interface.ts new file mode 100644 index 0000000000..3c6d5d6fc2 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.interface.ts @@ -0,0 +1,10 @@ +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse } from 'src/modules/browser/dto'; + +export interface IKeyInfoStrategy { + getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise; +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.spec.ts new file mode 100644 index 0000000000..c336ab9692 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.spec.ts @@ -0,0 +1,51 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { mockRedisConsumer } from 'src/__mocks__'; +import { KeyInfoManager } from 'src/modules/browser/services/keys-business/key-info-manager/key-info-manager'; +import { RedisDataType } from 'src/modules/browser/dto'; +import { IKeyInfoStrategy } from './key-info-manager.interface'; +import { UnsupportedTypeInfoStrategy } from './strategies/unsupported-type-info/unsupported-type-info.strategy'; + +class TestKeyInfoStrategy implements IKeyInfoStrategy { + public async getInfo() { + return null; + } +} +const testStrategy = new TestKeyInfoStrategy(); + +describe(' KeyInfoManager', () => { + let manager; + let browserTool; + let defaultStrategy; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + defaultStrategy = new UnsupportedTypeInfoStrategy(browserTool); + manager = new KeyInfoManager(defaultStrategy); + }); + it('Should return default strategy', () => { + const strategy = manager.getStrategy('undefined'); + expect(strategy).toEqual(defaultStrategy); + }); + it('Should add strategy to manager and get it back', () => { + manager.addStrategy(RedisDataType.String, testStrategy); + expect(manager.getStrategy(RedisDataType.String)).toEqual(testStrategy); + }); + it('Should support multiple strategies', () => { + manager.addStrategy('str1', testStrategy); + manager.addStrategy('str2', testStrategy); + manager.addStrategy('str3', testStrategy); + expect(manager.getStrategy('str1')).toEqual(testStrategy); + expect(manager.getStrategy('str2')).toEqual(testStrategy); + expect(manager.getStrategy('str3')).toEqual(testStrategy); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.ts new file mode 100644 index 0000000000..51320bae9f --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.ts @@ -0,0 +1,23 @@ +import { IKeyInfoStrategy } from './key-info-manager.interface'; + +export class KeyInfoManager { + private strategies = {}; + + private readonly defaultStrategy: IKeyInfoStrategy; + + constructor(defaultStrategy: IKeyInfoStrategy) { + this.defaultStrategy = defaultStrategy; + } + + addStrategy(name: string, strategy: IKeyInfoStrategy): void { + this.strategies[name] = strategy; + } + + getStrategy(name: string): IKeyInfoStrategy { + if (!this.strategies[name]) { + return this.defaultStrategy; + } + + return this.strategies[name]; + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/graph-type-info/graph-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/graph-type-info/graph-type-info.strategy.spec.ts new file mode 100644 index 0000000000..9659c54f39 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/graph-type-info/graph-type-info.strategy.spec.ts @@ -0,0 +1,158 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { + BrowserToolKeysCommands, + BrowserToolGraphCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { ReplyError } from 'src/models'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GraphTypeInfoStrategy } from './graph-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testGraph', + type: 'graphdata', + ttl: -1, + size: 50, + length: 10, +}; + +const mockGraphQueryReply = [ + [[1, 'count(r)']], + [[[3, getKeyInfoResponse.length]]], + [ + 'Cached execution: 1', + 'Query internal execution time: 0.093200 milliseconds', + ], +]; + +describe('GraphTypeInfoStrategy', () => { + let strategy: GraphTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new GraphTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + beforeEach(() => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + ], + ]); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolGraphCommands.GraphQuery, [ + key, + 'MATCH (r) RETURN count(r)', + '--compact', + ]) + .mockResolvedValue(mockGraphQueryReply); + }); + it('should return appropriate value', async () => { + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.Graph, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, RedisDataType.Graph); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.Graph, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + it('should return result without length', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolGraphCommands.GraphQuery, + message: "ERR unknown command 'graph.query", + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolGraphCommands.GraphQuery, [ + key, + 'MATCH (r) RETURN count(r)', + '--compact', + ]) + .mockResolvedValue(replyError); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.Graph, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, length: undefined }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/graph-type-info/graph-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/graph-type-info/graph-type-info.strategy.ts new file mode 100644 index 0000000000..a2365ca192 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/graph-type-info/graph-type-info.strategy.ts @@ -0,0 +1,67 @@ +import { Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolGraphCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +export class GraphTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('GraphTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${RedisDataType.Graph} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size] = result; + const length = await this.getNodesCount(clientOptions, key); + return { + name: key, + type, + ttl, + size: size || null, + length, + }; + } + } + + private async getNodesCount( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + ): Promise { + try { + const queryReply = await this.redisManager.execCommand( + clientOptions, + BrowserToolGraphCommands.GraphQuery, + [key, 'MATCH (r) RETURN count(r)', '--compact'], + ); + return queryReply[1][0][0][1]; + } catch (error) { + return undefined; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/hash-type-info/hash-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/hash-type-info/hash-type-info.strategy.spec.ts new file mode 100644 index 0000000000..5948ffc5d2 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/hash-type-info/hash-type-info.strategy.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { + BrowserToolHashCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { ReplyError } from 'src/models'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { HashTypeInfoStrategy } from './hash-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testHash', + type: 'hash', + ttl: -1, + size: 50, + length: 10, +}; + +describe('HashTypeInfoStrategy', () => { + let strategy: HashTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new HashTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + it('should return appropriate value', async () => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolHashCommands.HLen, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.Hash, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolHashCommands.HLen, key], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, RedisDataType.Hash); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolHashCommands.HLen, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.Hash, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/hash-type-info/hash-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/hash-type-info/hash-type-info.strategy.ts new file mode 100644 index 0000000000..78f77e6aea --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/hash-type-info/hash-type-info.strategy.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolHashCommands, + BrowserToolKeysCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +export class HashTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('HashTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${RedisDataType.Hash} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolHashCommands.HLen, key], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size, length] = result; + return { + name: key, + type, + ttl, + size: size || null, + length, + }; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/list-type-info/list-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/list-type-info/list-type-info.strategy.spec.ts new file mode 100644 index 0000000000..067aa5b837 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/list-type-info/list-type-info.strategy.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { + BrowserToolKeysCommands, + BrowserToolListCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { ReplyError } from 'src/models'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { ListTypeInfoStrategy } from './list-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testList', + type: 'list', + ttl: -1, + size: 50, + length: 10, +}; + +describe('ListTypeInfoStrategy', () => { + let strategy: ListTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new ListTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + it('should return appropriate value', async () => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolListCommands.LLen, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.List, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolListCommands.LLen, key], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, RedisDataType.List); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolListCommands.LLen, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.List, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/list-type-info/list-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/list-type-info/list-type-info.strategy.ts new file mode 100644 index 0000000000..b93c8c2cad --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/list-type-info/list-type-info.strategy.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolListCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +export class ListTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('ListTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${RedisDataType.List} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolListCommands.LLen, key], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size, length] = result; + return { + name: key, + type, + ttl, + size: size || null, + length, + }; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/rejson-rl-type-info/rejson-rl-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/rejson-rl-type-info/rejson-rl-type-info.strategy.spec.ts new file mode 100644 index 0000000000..dd65fe1d76 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/rejson-rl-type-info/rejson-rl-type-info.strategy.spec.ts @@ -0,0 +1,194 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { ReplyError } from 'src/models'; +import { + BrowserToolKeysCommands, + BrowserToolRejsonRlCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { RejsonRlTypeInfoStrategy } from './rejson-rl-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testJson', + type: 'ReJSON-RL', + ttl: -1, + size: 50, + length: 10, +}; + +describe('RejsonRlTypeInfoStrategy', () => { + let strategy: RejsonRlTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RejsonRlTypeInfoStrategy, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new RejsonRlTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + const path = '.'; + beforeEach(() => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + ], + ]); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + key, + path, + ]) + .mockResolvedValue('object'); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonObjLen, [ + key, + path, + ]) + .mockResolvedValue(10); + }); + it('should return appropriate value for key that store object', async () => { + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.JSON, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should return appropriate value for key that store string', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + key, + path, + ]) + .mockResolvedValue('string'); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonStrLen, [ + key, + path, + ]) + .mockResolvedValue(10); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.JSON, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should return appropriate value for key that store array', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + key, + path, + ]) + .mockResolvedValue('array'); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonArrLen, [ + key, + path, + ]) + .mockResolvedValue(10); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.JSON, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should return appropriate value for key that store not iterable type', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + key, + path, + ]) + .mockResolvedValue('boolean'); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.JSON, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, length: undefined }); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, RedisDataType.JSON); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.JSON, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/rejson-rl-type-info/rejson-rl-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/rejson-rl-type-info/rejson-rl-type-info.strategy.ts new file mode 100644 index 0000000000..46c96ccb82 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/rejson-rl-type-info/rejson-rl-type-info.strategy.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolRejsonRlCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +@Injectable() +export class RejsonRlTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('RejsonRlTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${RedisDataType.JSON} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size] = result; + const length = await this.getLength(clientOptions, key); + return { + name: key, + type, + ttl, + size: size || null, + length, + }; + } + } + + private async getLength( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + ): Promise { + try { + const objectKeyType = await this.redisManager.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonType, + [key, '.'], + ); + + switch (objectKeyType) { + case 'object': + return await this.redisManager.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonObjLen, + [key, '.'], + ); + case 'array': + return await this.redisManager.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonArrLen, + [key, '.'], + ); + case 'string': + return await this.redisManager.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonStrLen, + [key, '.'], + ); + default: + return undefined; + } + } catch (error) { + return undefined; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/set-type-info/set-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/set-type-info/set-type-info.strategy.spec.ts new file mode 100644 index 0000000000..da8e70dc3a --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/set-type-info/set-type-info.strategy.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { + BrowserToolKeysCommands, + BrowserToolSetCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { ReplyError } from 'src/models'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { SetTypeInfoStrategy } from './set-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testSet', + type: 'set', + ttl: -1, + size: 50, + length: 10, +}; + +describe('SetTypeInfoStrategy', () => { + let strategy: SetTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new SetTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + it('should return appropriate value', async () => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolSetCommands.SCard, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.Set, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolSetCommands.SCard, key], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, RedisDataType.Set); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolSetCommands.SCard, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.Set, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/set-type-info/set-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/set-type-info/set-type-info.strategy.ts new file mode 100644 index 0000000000..a76ad95303 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/set-type-info/set-type-info.strategy.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolSetCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +export class SetTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('SetTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${RedisDataType.Set} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolSetCommands.SCard, key], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size, length] = result; + return { + name: key, + type, + ttl, + size: size || null, + length, + }; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/stream-type-info/stream-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/stream-type-info/stream-type-info.strategy.spec.ts new file mode 100644 index 0000000000..ec3e84a4a9 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/stream-type-info/stream-type-info.strategy.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { ReplyError } from 'src/models'; +import { + BrowserToolKeysCommands, + BrowserToolStreamCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { StreamTypeInfoStrategy } from './stream-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testStream', + type: 'stream', + ttl: -1, + size: 50, + length: 10, +}; + +describe('StreamTypeInfoStrategy', () => { + let strategy: StreamTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new StreamTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + it('should return appropriate value', async () => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolStreamCommands.XLen, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.Stream, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolStreamCommands.XLen, key], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, RedisDataType.Stream); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolStreamCommands.XLen, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.Stream, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/stream-type-info/stream-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/stream-type-info/stream-type-info.strategy.ts new file mode 100644 index 0000000000..617771a6ad --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/stream-type-info/stream-type-info.strategy.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolStreamCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +export class StreamTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('StreamTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${RedisDataType.Stream} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolStreamCommands.XLen, key], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size, length] = result; + return { + name: key, + type, + ttl, + size: size || null, + length, + }; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/string-type-info/string-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/string-type-info/string-type-info.strategy.spec.ts new file mode 100644 index 0000000000..ac1575826e --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/string-type-info/string-type-info.strategy.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { + BrowserToolKeysCommands, + BrowserToolStringCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { ReplyError } from 'src/models'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { StringTypeInfoStrategy } from './string-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testString', + type: 'string', + ttl: -1, + size: 50, + length: 10, +}; + +describe('StringTypeInfoStrategy', () => { + let strategy: StringTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new StringTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + it('should return appropriate value', async () => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolStringCommands.StrLen, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.String, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolStringCommands.StrLen, key], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, RedisDataType.String); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolStringCommands.StrLen, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.String, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/string-type-info/string-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/string-type-info/string-type-info.strategy.ts new file mode 100644 index 0000000000..3bcf3b6f3a --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/string-type-info/string-type-info.strategy.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolStringCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +export class StringTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('StringTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${RedisDataType.String} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolStringCommands.StrLen, key], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size, length] = result; + return { + name: key, + type, + ttl, + size: size || null, + length, + }; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/ts-type-info/ts-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/ts-type-info/ts-type-info.strategy.spec.ts new file mode 100644 index 0000000000..7e90d20916 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/ts-type-info/ts-type-info.strategy.spec.ts @@ -0,0 +1,158 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { + BrowserToolKeysCommands, + BrowserToolTSCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { ReplyError } from 'src/models'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { TSTypeInfoStrategy } from './ts-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testTS', + type: 'TSDB-TYPE', + ttl: -1, + size: 50, + length: 10, +}; + +const mockTSInfoReply = [ + 'totalSamples', + 10, + 'memoryUsage', + 4239, + 'firstTimestamp', + 0, + 'lastTimestamp', + 0, + 'retentionTime', + 6000, + 'chunkCount', + 1, + 'chunkSize', + 4096, +]; + +describe('TSTypeInfoStrategy', () => { + let strategy: TSTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new TSTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + beforeEach(() => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + ], + ]); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolTSCommands.TSInfo, [key]) + .mockResolvedValue(mockTSInfoReply); + }); + it('should return appropriate value', async () => { + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.TS, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, RedisDataType.TS); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.TS, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + it('should return result without length', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolTSCommands.TSInfo, + message: "ERR unknown command 'ts.info'", + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolTSCommands.TSInfo, [key]) + .mockResolvedValue(replyError); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.TS, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, length: undefined }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/ts-type-info/ts-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/ts-type-info/ts-type-info.strategy.ts new file mode 100644 index 0000000000..f872500495 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/ts-type-info/ts-type-info.strategy.ts @@ -0,0 +1,69 @@ +import { Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { convertStringsArrayToObject } from 'src/utils'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolTSCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +export class TSTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('TSTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${RedisDataType.TS} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size] = result; + const length = await this.getTotalSamples(clientOptions, key); + return { + name: key, + type, + ttl, + size: size || null, + length, + }; + } + } + + private async getTotalSamples( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + ): Promise { + try { + const info = await this.redisManager.execCommand( + clientOptions, + BrowserToolTSCommands.TSInfo, + [key], + ); + const { totalsamples } = convertStringsArrayToObject(info); + return totalsamples; + } catch (error) { + return undefined; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/unsupported-type-info/unsupported-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/unsupported-type-info/unsupported-type-info.strategy.spec.ts new file mode 100644 index 0000000000..27839659ab --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/unsupported-type-info/unsupported-type-info.strategy.spec.ts @@ -0,0 +1,115 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { ReplyError } from 'src/models'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { GetKeyInfoResponse } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { UnsupportedTypeInfoStrategy } from './unsupported-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testKey', + type: 'custom-type', + ttl: -1, + size: 50, +}; + +describe('UnsupportedTypeInfoStrategy', () => { + let strategy: UnsupportedTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new UnsupportedTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + it('should return appropriate value', async () => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + 'custom-type', + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, 'custom-type'); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + 'custom-type', + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/unsupported-type-info/unsupported-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/unsupported-type-info/unsupported-type-info.strategy.ts new file mode 100644 index 0000000000..47f1e1df54 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/unsupported-type-info/unsupported-type-info.strategy.ts @@ -0,0 +1,46 @@ +import { Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse } from 'src/modules/browser/dto'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +export class UnsupportedTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('UnsupportedTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${type} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size] = result; + return { + name: key, + type, + ttl, + size: size || null, + }; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/z-set-type-info/z-set-type-info.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/z-set-type-info/z-set-type-info.strategy.spec.ts new file mode 100644 index 0000000000..8ca543dfda --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/z-set-type-info/z-set-type-info.strategy.spec.ts @@ -0,0 +1,124 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { ReplyError } from 'src/models'; +import { + BrowserToolKeysCommands, + BrowserToolZSetCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { ZSetTypeInfoStrategy } from './z-set-type-info.strategy'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testZSet', + type: 'zset', + ttl: -1, + size: 50, + length: 10, +}; + +describe('ZSetTypeInfoStrategy', () => { + let strategy: ZSetTypeInfoStrategy; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + strategy = new ZSetTypeInfoStrategy(browserTool); + }); + + describe('getInfo', () => { + const key = getKeyInfoResponse.name; + it('should return appropriate value', async () => { + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolZSetCommands.ZCard, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [null, 50], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.ZSet, + ); + + expect(result).toEqual(getKeyInfoResponse); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: BrowserToolKeysCommands.Type, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolZSetCommands.ZCard, key], + ]) + .mockResolvedValue([replyError, []]); + + try { + await strategy.getInfo(mockClientOptions, key, RedisDataType.ZSet); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should return size with null value', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: BrowserToolKeysCommands.MemoryUsage, + message: "ERR unknown command 'memory'", + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolZSetCommands.ZCard, key], + ]) + .mockResolvedValue([ + null, + [ + [null, -1], + [replyError, null], + [null, 10], + ], + ]); + + const result = await strategy.getInfo( + mockClientOptions, + key, + RedisDataType.ZSet, + ); + + expect(result).toEqual({ ...getKeyInfoResponse, size: null }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/z-set-type-info/z-set-type-info.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/z-set-type-info/z-set-type-info.strategy.ts new file mode 100644 index 0000000000..667cd71983 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/z-set-type-info/z-set-type-info.strategy.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolZSetCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IKeyInfoStrategy } from '../../key-info-manager.interface'; + +export class ZSetTypeInfoStrategy implements IKeyInfoStrategy { + private logger = new Logger('ZSetTypeInfoStrategy'); + + private readonly redisManager: BrowserToolService; + + constructor(redisManager: BrowserToolService) { + this.redisManager = redisManager; + } + + public async getInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + type: string, + ): Promise { + this.logger.log(`Getting ${RedisDataType.ZSet} type info.`); + const [ + transactionError, + transactionResults, + ] = await this.redisManager.execPipeline(clientOptions, [ + [BrowserToolKeysCommands.Ttl, key], + [BrowserToolKeysCommands.MemoryUsage, key, 'samples', '0'], + [BrowserToolZSetCommands.ZCard, key], + ]); + if (transactionError) { + throw transactionError; + } else { + const result = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [ttl, size, length] = result; + return { + name: key, + type, + ttl, + size: size || null, + length, + }; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.spec.ts new file mode 100644 index 0000000000..0145d6b2d4 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.spec.ts @@ -0,0 +1,439 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { when } from 'jest-when'; +import { get } from 'lodash'; +import { ReplyError } from 'src/models/redis-client'; +import { + mockBrowserAnalyticsService, + mockOSSClusterDatabaseEntity, + mockRedisClusterConsumer, + mockRedisConsumer, + mockRedisNoPermError, + mockRepository, + mockSettingsProvider, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { + GetKeyInfoResponse, + GetKeysDto, + GetKeysWithDetailsResponse, + RedisDataType, + RenameKeyDto, +} from 'src/modules/browser/dto'; +import { + ConnectionType, + DatabaseInstanceEntity, +} from 'src/modules/core/models/database-instance.entity'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { + BrowserToolClusterService, +} from 'src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service'; +import { KeysBusinessService } from './keys-business.service'; +import { StringTypeInfoStrategy } from './key-info-manager/strategies/string-type-info/string-type-info.strategy'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +const getKeyInfoResponse: GetKeyInfoResponse = { + name: 'testString', + type: 'string', + ttl: -1, + size: 50, +}; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockGetKeysWithDetailsResponse: GetKeysWithDetailsResponse = { + cursor: 0, + total: 1, + scanned: 0, + keys: [getKeyInfoResponse], +}; + +describe('KeysBusinessService', () => { + let service; + let instancesBusinessService; + let browserTool; + let standaloneScanner; + let clusterScanner; + let stringTypeInfoManager; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + KeysBusinessService, + { + provide: BrowserAnalyticsService, + useFactory: mockBrowserAnalyticsService, + }, + { + provide: getRepositoryToken(DatabaseInstanceEntity), + useFactory: mockRepository, + }, + { + provide: InstancesBusinessService, + useFactory: () => ({ + getOneById: jest.fn(), + }), + }, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + { + provide: BrowserToolClusterService, + useFactory: mockRedisClusterConsumer, + }, + { + provide: StringTypeInfoStrategy, + useFactory: () => ({ + getInfo: jest.fn(), + }), + }, + { + provide: 'SETTINGS_PROVIDER', + useFactory: mockSettingsProvider, + }, + ], + }).compile(); + + service = module.get(KeysBusinessService); + instancesBusinessService = module.get( + InstancesBusinessService, + ); + browserTool = module.get(BrowserToolService); + const scannerManager = get(service, 'scanner'); + const keyInfoManager = get(service, 'keyInfoManager'); + standaloneScanner = scannerManager.getStrategy(ConnectionType.STANDALONE); + clusterScanner = scannerManager.getStrategy(ConnectionType.CLUSTER); + stringTypeInfoManager = keyInfoManager.getStrategy(RedisDataType.String); + }); + + describe('getKeyInfo', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Type, [ + getKeyInfoResponse.name, + ]) + .mockResolvedValue(RedisDataType.String); + }); + + it('should return appropriate value', async () => { + const mockResult: GetKeyInfoResponse = { + ...getKeyInfoResponse, + length: 10, + }; + stringTypeInfoManager.getInfo = jest.fn().mockResolvedValue(mockResult); + + const result = await service.getKeyInfo( + mockClientOptions, + getKeyInfoResponse.name, + ); + + expect(result).toEqual(mockResult); + }); + it('throw NotFound error when key not found for getKeyInfo', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Type, [ + getKeyInfoResponse.name, + ]) + .mockResolvedValue('none'); + + await expect( + service.getKeyInfo(mockClientOptions, getKeyInfoResponse.name), + ).rejects.toThrow(NotFoundException); + }); + it("user don't have required permissions for getKeyInfo", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'TYPE', + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Type, [ + getKeyInfoResponse.name, + ]) + .mockRejectedValue(replyError); + + await expect( + service.getKeyInfo(mockClientOptions, getKeyInfoResponse.name), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getKeys', () => { + const getKeysDto: GetKeysDto = { cursor: '0', count: 15 }; + beforeEach(() => { + instancesBusinessService.getOneById.mockResolvedValue( + mockStandaloneDatabaseEntity, + ); + }); + it('should return appropriate value for standalone database', async () => { + standaloneScanner.getKeys = jest + .fn() + .mockResolvedValue([mockGetKeysWithDetailsResponse]); + + const result = await service.getKeys(mockClientOptions, getKeysDto); + + expect(standaloneScanner.getKeys).toHaveBeenCalled(); + expect(result).toEqual([mockGetKeysWithDetailsResponse]); + }); + it('should return appropriate value for cluster', async () => { + const clientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockOSSClusterDatabaseEntity.id, + }; + instancesBusinessService.getOneById.mockResolvedValue( + mockOSSClusterDatabaseEntity, + ); + clusterScanner.getKeys = jest + .fn() + .mockResolvedValue([mockGetKeysWithDetailsResponse]); + + const result = await service.getKeys(clientOptions, getKeysDto); + + expect(clusterScanner.getKeys).toHaveBeenCalled(); + expect(result).toEqual([mockGetKeysWithDetailsResponse]); + }); + it("user don't have required permissions for getKeys", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'SCAN', + }; + standaloneScanner.getKeys = jest.fn().mockRejectedValue(replyError); + + await expect( + service.getKeys(mockClientOptions, getKeysDto), + ).rejects.toThrow(ForbiddenException); + }); + it('scan per type not supported', async () => { + const dto: GetKeysDto = { + ...getKeysDto, + type: RedisDataType.String, + }; + const replyError: ReplyError = { + name: 'ReplyError', + message: 'ERR syntax error', + command: 'SCAN', + }; + standaloneScanner.getKeys = jest.fn().mockRejectedValue(replyError); + + try { + await service.getKeys(mockClientOptions, dto); + fail('Should throw an error'); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual( + ERROR_MESSAGES.SCAN_PER_KEY_TYPE_NOT_SUPPORT(), + ); + } + }); + }); + + describe('deleteKeys', () => { + const keyNames = ['testString1', 'testString2']; + + it('succeeded to delete keys', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Del, [ + ...keyNames, + ]) + .mockResolvedValue(keyNames.length); + + const result = await service.deleteKeys(mockClientOptions, [ + 'testString1', + 'testString2', + ]); + expect(result).toEqual({ affected: keyNames.length }); + }); + it('keys not found', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Del, [ + ...keyNames, + ]) + .mockResolvedValue(null); + + await expect( + service.deleteKeys(mockClientOptions, keyNames), + ).rejects.toThrow(NotFoundException); + }); + it("user don't have required permissions for deleteKeys", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'DEL', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.deleteKeys(mockClientOptions, keyNames), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('renameKey', () => { + const renameKeyDto: RenameKeyDto = { + keyName: 'testString1', + newKeyName: 'testString2', + }; + + it('succeeded to rename key', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + renameKeyDto.keyName, + ]) + .mockResolvedValue(true); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.RenameNX, [ + renameKeyDto.keyName, + renameKeyDto.newKeyName, + ]) + .mockResolvedValue(1); + + await expect( + service.renameKey(mockClientOptions, renameKeyDto), + ).resolves.not.toThrow(); + }); + it('key with keyName not exist', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + renameKeyDto.keyName, + ]) + .mockResolvedValue(false); + + await expect( + service.renameKey(mockClientOptions, renameKeyDto), + ).rejects.toThrow(NotFoundException); + }); + it('key with newKeyName already exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + renameKeyDto.keyName, + ]) + .mockResolvedValue(true); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + renameKeyDto.keyName, + renameKeyDto.newKeyName, + ]) + .mockResolvedValue(0); + + await expect( + service.renameKey(mockClientOptions, renameKeyDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for renameKey", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'RENAMENX', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.renameKey(mockClientOptions, renameKeyDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('updateTtl', () => { + const keyName = 'testString'; + it('set expiration time', async () => { + const dto = { keyName, ttl: 1000 }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Ttl, [keyName]) + .mockResolvedValue(-1); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Expire, [ + keyName, + dto.ttl, + ]) + .mockResolvedValue(1); + + const result = await service.updateTtl(mockClientOptions, dto); + + expect(result).toEqual({ ttl: dto.ttl }); + }); + it('remove the existing timeout on key', async () => { + const dto = { keyName, ttl: -1 }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Ttl, [keyName]) + .mockResolvedValue(1000); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Persist, [ + keyName, + ]) + .mockResolvedValue(1); + + const result = await service.updateTtl(mockClientOptions, dto); + expect(result).toEqual({ ttl: dto.ttl }); + }); + it('key not found', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Expire, [ + keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.updateTtl(mockClientOptions, { keyName, ttl: 1000 }), + ).rejects.toThrow(NotFoundException); + }); + it("user don't have required permissions for updateTtl", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'EXPIRE', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.updateTtl(mockClientOptions, { keyName, ttl: 1000 }), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('removeKeyExpiration', () => { + const keyName = 'testString'; + it('should remove key expiration', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Ttl, [keyName]) + .mockResolvedValue(1000); + + const result = await service.removeKeyExpiration(mockClientOptions, { + keyName, + ttl: -1, + }); + expect(result).toEqual({ ttl: -1 }); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolKeysCommands.Persist, + [keyName], + ); + }); + it('key not found', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Ttl, [keyName]) + .mockResolvedValue(-2); + + await expect( + service.removeKeyExpiration(mockClientOptions, { keyName, ttl: -1 }), + ).rejects.toThrow(NotFoundException); + }); + it("user don't have required permissions for removeKeyExpiration", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'TTL', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.removeKeyExpiration(mockClientOptions, { keyName, ttl: -1 }), + ).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.ts b/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.ts new file mode 100644 index 0000000000..e38471f234 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.ts @@ -0,0 +1,345 @@ +import { + BadRequestException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { RedisErrorCodes } from 'src/constants'; +import { catchAclError } from 'src/utils'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + DeleteKeysResponse, + GetKeyInfoResponse, + GetKeysDto, + GetKeysWithDetailsResponse, + RenameKeyDto, + RenameKeyResponse, + UpdateKeyTtlDto, + KeyTtlResponse, + RedisDataType, +} from 'src/modules/browser/dto'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolClusterService, +} from 'src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service'; +import { ConnectionType } from 'src/modules/core/models/database-instance.entity'; +import { Scanner } from 'src/modules/browser/services/keys-business/scanner/scanner'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { StandaloneStrategy } from './scanner/strategies/standalone.strategy'; +import { ClusterStrategy } from './scanner/strategies/cluster.strategy'; +import { KeyInfoManager } from './key-info-manager/key-info-manager'; +import { + UnsupportedTypeInfoStrategy, +} from './key-info-manager/strategies/unsupported-type-info/unsupported-type-info.strategy'; +import { StringTypeInfoStrategy } from './key-info-manager/strategies/string-type-info/string-type-info.strategy'; +import { HashTypeInfoStrategy } from './key-info-manager/strategies/hash-type-info/hash-type-info.strategy'; +import { ListTypeInfoStrategy } from './key-info-manager/strategies/list-type-info/list-type-info.strategy'; +import { SetTypeInfoStrategy } from './key-info-manager/strategies/set-type-info/set-type-info.strategy'; +import { ZSetTypeInfoStrategy } from './key-info-manager/strategies/z-set-type-info/z-set-type-info.strategy'; +import { StreamTypeInfoStrategy } from './key-info-manager/strategies/stream-type-info/stream-type-info.strategy'; +import { + RejsonRlTypeInfoStrategy, +} from './key-info-manager/strategies/rejson-rl-type-info/rejson-rl-type-info.strategy'; +import { TSTypeInfoStrategy } from './key-info-manager/strategies/ts-type-info/ts-type-info.strategy'; +import { GraphTypeInfoStrategy } from './key-info-manager/strategies/graph-type-info/graph-type-info.strategy'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +@Injectable() +export class KeysBusinessService { + private logger = new Logger('KeysBusinessService'); + + private scanner; + + private keyInfoManager; + + constructor( + private instancesBusinessService: InstancesBusinessService, + private browserTool: BrowserToolService, + private browserToolCluster: BrowserToolClusterService, + private browserAnalyticsService: BrowserAnalyticsService, + @Inject('SETTINGS_PROVIDER') + private settingsService: ISettingsProvider, + ) { + this.scanner = new Scanner(); + this.keyInfoManager = new KeyInfoManager( + new UnsupportedTypeInfoStrategy(browserTool), + ); + this.scanner.addStrategy( + ConnectionType.STANDALONE, + new StandaloneStrategy(browserTool, settingsService), + ); + this.scanner.addStrategy( + ConnectionType.CLUSTER, + new ClusterStrategy(browserToolCluster, settingsService), + ); + this.scanner.addStrategy( + ConnectionType.SENTINEL, + new StandaloneStrategy(browserTool, settingsService), + ); + this.keyInfoManager.addStrategy( + RedisDataType.String, + new StringTypeInfoStrategy(browserTool), + ); + this.keyInfoManager.addStrategy( + RedisDataType.Hash, + new HashTypeInfoStrategy(browserTool), + ); + this.keyInfoManager.addStrategy( + RedisDataType.List, + new ListTypeInfoStrategy(browserTool), + ); + this.keyInfoManager.addStrategy( + RedisDataType.Set, + new SetTypeInfoStrategy(browserTool), + ); + this.keyInfoManager.addStrategy( + RedisDataType.ZSet, + new ZSetTypeInfoStrategy(browserTool), + ); + this.keyInfoManager.addStrategy( + RedisDataType.Stream, + new StreamTypeInfoStrategy(browserTool), + ); + this.keyInfoManager.addStrategy( + RedisDataType.JSON, + new RejsonRlTypeInfoStrategy(browserTool), + ); + this.keyInfoManager.addStrategy( + RedisDataType.TS, + new TSTypeInfoStrategy(browserTool), + ); + this.keyInfoManager.addStrategy( + RedisDataType.Graph, + new GraphTypeInfoStrategy(browserTool), + ); + } + + public async getKeys( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetKeysDto, + ): Promise { + try { + this.logger.log('Getting keys with details.'); + // todo: refactor. no need entire entity here + const databaseInstance = await this.instancesBusinessService.getOneById( + clientOptions.instanceId, + ); + const scanner = this.scanner.getStrategy(databaseInstance.connectionType); + const result = await scanner.getKeys(clientOptions, dto); + this.browserAnalyticsService.sendKeysScannedEvent( + clientOptions.instanceId, + dto.match, + dto.type, + { + databaseSize: result.reduce((prev, cur) => prev + cur.total, 0), + numberOfKeysScanned: result.reduce( + (prev, cur) => prev + cur.scanned, + 0, + ), + scanCount: dto.count, + }, + ); + return result; + } catch (error) { + this.logger.error( + `Failed to get keys with details info. ${error.message}.`, + ); + if ( + error.message.includes(RedisErrorCodes.CommandSyntaxError) + && dto.type + ) { + throw new BadRequestException( + ERROR_MESSAGES.SCAN_PER_KEY_TYPE_NOT_SUPPORT(), + ); + } + throw catchAclError(error); + } + } + + public async getKeyInfo( + clientOptions: IFindRedisClientInstanceByOptions, + key: string, + ): Promise { + this.logger.log('Getting key info.'); + try { + const type = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Type, + [key], + ); + if (type === 'none') { + this.logger.error(`Failed to get key info. Not found key: ${key}`); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + const infoManager = this.keyInfoManager.getStrategy(type); + const result = await infoManager.getInfo(clientOptions, key, type); + this.logger.log('Succeed to get key info'); + return result; + } catch (error) { + this.logger.error('Failed to get key info.', error); + throw catchAclError(error); + } + } + + public async deleteKeys( + clientOptions: IFindRedisClientInstanceByOptions, + keys: string[], + ): Promise { + this.logger.log('Deleting keys'); + let result; + try { + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Del, + keys, + ); + } catch (error) { + this.logger.error('Failed to delete keys.', error); + catchAclError(error); + } + if (!result) { + this.logger.error('Failed to delete keys. Not Found keys'); + throw new NotFoundException(); + } + this.browserAnalyticsService.sendKeysDeletedEvent( + clientOptions.instanceId, + result, + ); + this.logger.log('Succeed to delete keys'); + return { affected: result }; + } + + public async renameKey( + clientOptions: IFindRedisClientInstanceByOptions, + dto: RenameKeyDto, + ): Promise { + this.logger.log('Renaming key'); + const { keyName, newKeyName } = dto; + let result; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to rename key. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.RenameNX, + [keyName, newKeyName], + ); + } catch (error) { + this.logger.error('Failed to rename key.', error); + catchAclError(error); + } + if (!result) { + this.logger.error( + `Failed to rename key. ${ERROR_MESSAGES.NEW_KEY_NAME_EXIST} key: ${newKeyName}`, + ); + throw new BadRequestException(ERROR_MESSAGES.NEW_KEY_NAME_EXIST); + } + this.logger.log('Succeed to rename key'); + return { keyName: newKeyName }; + } + + public async updateTtl( + clientOptions: IFindRedisClientInstanceByOptions, + dto: UpdateKeyTtlDto, + ): Promise { + if (dto.ttl === -1) { + return await this.removeKeyExpiration(clientOptions, dto); + } + return await this.setKeyExpiration(clientOptions, dto); + } + + public async setKeyExpiration( + clientOptions: IFindRedisClientInstanceByOptions, + dto: UpdateKeyTtlDto, + ): Promise { + this.logger.log('Setting a timeout on key.'); + const { keyName, ttl } = dto; + let currentTtl; + let result; + try { + currentTtl = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Ttl, + [keyName], + ); + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Expire, + [keyName, ttl], + ); + } catch (error) { + this.logger.error('Failed to set a timeout on key.', error); + catchAclError(error); + } + if (!result) { + this.logger.error( + `Failed to set a timeout on key. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + throw new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST); + } + this.logger.log('Succeed to set a timeout on key.'); + this.browserAnalyticsService.sendKeyTTLChangedEvent( + clientOptions.instanceId, + ttl >= 0 ? ttl : -2, + currentTtl, + ); + return { ttl: ttl >= 0 ? ttl : -2 }; + } + + public async removeKeyExpiration( + clientOptions: IFindRedisClientInstanceByOptions, + dto: UpdateKeyTtlDto, + ): Promise { + this.logger.log('Removing the existing timeout on key.'); + const { keyName } = dto; + try { + const currentTtl = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Ttl, + [keyName], + ); + if (currentTtl === -2) { + this.logger.error( + `Failed to remove the existing timeout on key. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + if (currentTtl > 0) { + await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Persist, + [keyName], + ); + this.browserAnalyticsService.sendKeyTTLChangedEvent( + clientOptions.instanceId, + -1, + currentTtl, + ); + } + } catch (error) { + this.logger.error('Failed to remove the existing timeout on key.', error); + catchAclError(error); + } + this.logger.log('Succeed to remove the existing timeout on key.'); + return { ttl: -1 }; + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.interface.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.interface.ts new file mode 100644 index 0000000000..79b7cb669d --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.interface.ts @@ -0,0 +1,25 @@ +import { RedisDataType } from 'src/modules/browser/dto'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; + +interface IGetKeysArgs { + cursor: string; + count?: number; + match?: string; + type?: RedisDataType; +} + +export interface IGetNodeKeysResult { + total: number; + scanned: number; + cursor: number; + keys: any[]; + host?: string; + port?: number; +} + +export interface IScannerStrategy { + getKeys( + clientOptions: IFindRedisClientInstanceByOptions, + args: IGetKeysArgs, + ): Promise; +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.spec.ts new file mode 100644 index 0000000000..37b772583a --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.spec.ts @@ -0,0 +1,101 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Scanner } from 'src/modules/browser/services/keys-business/scanner/scanner'; +import { IScannerStrategy } from 'src/modules/browser/services/keys-business/scanner/scanner.interface'; +import { + BrowserToolClusterService, +} from 'src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service'; +import { ConnectionType } from 'src/modules/core/models/database-instance.entity'; +import { ClusterStrategy } from 'src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { mockRedisConsumer, mockSettingsProvider } from 'src/__mocks__'; +import { StandaloneStrategy } from 'src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; + +let scanner; +let browserToolCluster; +let browserTool; +let settingsProvider; + +class TestScanStrategy implements IScannerStrategy { + public async getKeys() { + return []; + } +} +const strategyName = 'testStrategy'; +const testStrategy = new TestScanStrategy(); + +describe('Scanner Manager', () => { + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + Scanner, + { + provide: BrowserToolClusterService, + useFactory: () => ({ + execCommand: jest.fn(), + execCommandFromNodes: jest.fn(), + execCommandFromNode: jest.fn(), + execPipeline: jest.fn(), + }), + }, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + { + provide: 'SETTINGS_PROVIDER', + useFactory: mockSettingsProvider, + }, + ], + }).compile(); + + scanner = module.get(Scanner); + settingsProvider = module.get('SETTINGS_PROVIDER'); + browserToolCluster = module.get( + BrowserToolClusterService, + ); + browserTool = module.get(BrowserToolService); + }); + it('Should throw error if no strategy', () => { + try { + scanner.getStrategy(strategyName); + } catch (e) { + expect(e.message).toEqual(`Unsupported scan strategy: ${strategyName}`); + } + }); + it('Should add strategy to scanner and get it back', () => { + scanner.addStrategy(strategyName, testStrategy); + expect(scanner.getStrategy(strategyName)).toEqual(testStrategy); + }); + it('Should support multiple strategies', () => { + scanner.addStrategy('str1', testStrategy); + scanner.addStrategy('str2', testStrategy); + scanner.addStrategy('str3', testStrategy); + expect(scanner.getStrategy('str1')).toEqual(testStrategy); + expect(scanner.getStrategy('str2')).toEqual(testStrategy); + expect(scanner.getStrategy('str3')).toEqual(testStrategy); + }); + it('Should support Standalone and Cluster strategies', () => { + scanner.addStrategy( + ConnectionType.CLUSTER, + new ClusterStrategy(browserToolCluster, settingsProvider), + ); + scanner.addStrategy( + ConnectionType.STANDALONE, + new StandaloneStrategy(browserTool, settingsProvider), + ); + scanner.addStrategy( + ConnectionType.SENTINEL, + new StandaloneStrategy(browserTool, settingsProvider), + ); + expect(scanner.getStrategy(ConnectionType.CLUSTER)).toBeInstanceOf( + ClusterStrategy, + ); + expect(scanner.getStrategy(ConnectionType.STANDALONE)).toBeInstanceOf( + StandaloneStrategy, + ); + expect(scanner.getStrategy(ConnectionType.SENTINEL)).toBeInstanceOf( + StandaloneStrategy, + ); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.ts new file mode 100644 index 0000000000..2e91ad2637 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { IScannerStrategy } from './scanner.interface'; + +@Injectable() +export class Scanner { + private strategies = {}; + + addStrategy(name: string, strategy: IScannerStrategy): void { + this.strategies[name] = strategy; + } + + getStrategy(name: string): IScannerStrategy { + if (!this.strategies[name]) { + throw new Error(`Unsupported scan strategy: ${name}`); + } + + return this.strategies[name]; + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/abstract.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/abstract.strategy.spec.ts new file mode 100644 index 0000000000..4a9e7caed4 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/abstract.strategy.spec.ts @@ -0,0 +1,165 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisWrongTypeError, + mockSettingsProvider, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { ReplyError } from 'src/models'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { StandaloneStrategy } from 'src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy'; +import { AbstractStrategy } from 'src/modules/browser/services/keys-business/scanner/strategies/abstract.strategy'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockKeyInfo: GetKeyInfoResponse = { + name: 'testString', + type: 'string', + ttl: -1, + size: 50, +}; + +describe('RedisScannerAbstract', () => { + let scannerInstance: AbstractStrategy; + let browserTool: BrowserToolService; + let settingsProvider: ISettingsProvider; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + { + provide: 'SETTINGS_PROVIDER', + useFactory: mockSettingsProvider, + }, + ], + }).compile(); + + browserTool = await module.get(BrowserToolService); + settingsProvider = module.get('SETTINGS_PROVIDER'); + scannerInstance = new StandaloneStrategy(browserTool, settingsProvider); + }); + + describe('getKeysInfo', () => { + const keys = ['key1', 'key2']; + beforeEach(() => { + when(browserTool.execPipeline) + .calledWith( + mockClientOptions, + keys.map((key: string) => [BrowserToolKeysCommands.Ttl, key]), + ) + .mockResolvedValue([null, Array(keys.length).fill([null, -1])]); + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, keys[0]], + ...keys.map((key: string) => [ + BrowserToolKeysCommands.MemoryUsage, + key, + 'samples', + '0', + ]), + ]) + .mockResolvedValue([ + null, + [[null, -1], ...Array(keys.length).fill([null, 50])], + ]); + when(browserTool.execPipeline) + .calledWith( + mockClientOptions, + keys.map((key: string) => [BrowserToolKeysCommands.Type, key]), + ) + .mockResolvedValue([null, Array(keys.length).fill([null, 'string'])]); + }); + it('should return correct keys info', async () => { + const mockResult: GetKeyInfoResponse[] = keys.map((key) => ({ + ...mockKeyInfo, + name: key, + })); + + const result = await scannerInstance.getKeysInfo(mockClientOptions, keys); + + expect(result).toEqual(mockResult); + }); + it('should not call TYPE pipeline for keys with known type', async () => { + const mockResult: GetKeyInfoResponse[] = keys.map((key) => ({ + ...mockKeyInfo, + name: key, + })); + + const result = await scannerInstance.getKeysInfo( + mockClientOptions, + keys, + RedisDataType.String, + ); + + expect(result).toEqual(mockResult); + expect(browserTool.execPipeline).not.toHaveBeenCalledWith( + mockClientOptions, + keys.map((key: string) => [BrowserToolKeysCommands.Type, key]), + ); + }); + it('should throw transaction error for SIZE', async () => { + const transactionError: ReplyError = { + ...mockRedisWrongTypeError, + command: BrowserToolKeysCommands.MemoryUsage, + }; + when(browserTool.execPipeline) + .calledWith(mockClientOptions, [ + [BrowserToolKeysCommands.Ttl, keys[0]], + ...keys.map((key: string) => [ + BrowserToolKeysCommands.MemoryUsage, + key, + 'samples', + '0', + ]), + ]) + .mockResolvedValue([transactionError, null]); + + await expect( + scannerInstance.getKeysInfo(mockClientOptions, keys), + ).rejects.toEqual(transactionError); + }); + it('should throw transaction error for Type', async () => { + const transactionError: ReplyError = { + ...mockRedisWrongTypeError, + command: BrowserToolKeysCommands.Type, + }; + when(browserTool.execPipeline) + .calledWith( + mockClientOptions, + keys.map((key: string) => [BrowserToolKeysCommands.Type, key]), + ) + .mockResolvedValue([transactionError, null]); + + await expect( + scannerInstance.getKeysInfo(mockClientOptions, keys), + ).rejects.toEqual(transactionError); + }); + it('should throw transaction error for TTL', async () => { + const transactionError: ReplyError = { + ...mockRedisWrongTypeError, + command: BrowserToolKeysCommands.Ttl, + }; + when(browserTool.execPipeline) + .calledWith( + mockClientOptions, + keys.map((key: string) => [BrowserToolKeysCommands.Ttl, key]), + ) + .mockResolvedValue([transactionError, null]); + + await expect( + scannerInstance.getKeysInfo(mockClientOptions, keys), + ).rejects.toEqual(transactionError); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/abstract.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/abstract.strategy.ts new file mode 100644 index 0000000000..ab44eefca2 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/abstract.strategy.ts @@ -0,0 +1,103 @@ +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { GetKeyInfoResponse, RedisDataType } from 'src/modules/browser/dto'; +import { IRedisConsumer, ReplyError } from 'src/models'; +import { IScannerStrategy } from '../scanner.interface'; + +export abstract class AbstractStrategy implements IScannerStrategy { + protected redisConsumer: IRedisConsumer; + + protected constructor(redisConsumer: IRedisConsumer) { + this.redisConsumer = redisConsumer; + } + + abstract getKeys(clientOptions, args); + + public async getKeysInfo( + clientOptions: IFindRedisClientInstanceByOptions, + keys: string[], + type?: RedisDataType, + ): Promise { + const sizeResults = await this.getKeysSize(clientOptions, keys); + const typeResults = type + ? Array(keys.length).fill(type) + : await this.getKeysType(clientOptions, keys); + const ttlResults = await this.getKeysTtl(clientOptions, keys); + return keys.map( + (key: string, index: number): GetKeyInfoResponse => ({ + name: key, + type: typeResults[index], + ttl: ttlResults[index], + size: sizeResults[index], + }), + ); + } + + protected async getKeysTtl( + clientOptions: IFindRedisClientInstanceByOptions, + keys: string[], + ): Promise { + const [ + transactionError, + transactionResults, + ] = await this.redisConsumer.execPipeline( + clientOptions, + keys.map((key: string) => [BrowserToolKeysCommands.Ttl, key]), + ); + if (transactionError) { + throw transactionError; + } else { + return transactionResults.map((item: [ReplyError, any]) => item[1]); + } + } + + protected async getKeysType( + clientOptions: IFindRedisClientInstanceByOptions, + keys: string[], + ): Promise { + const [ + transactionError, + transactionResults, + ] = await this.redisConsumer.execPipeline( + clientOptions, + keys.map((key: string) => [BrowserToolKeysCommands.Type, key]), + ); + if (transactionError) { + throw transactionError; + } else { + return transactionResults.map((item: [ReplyError, any]) => item[1]); + } + } + + protected async getKeysSize( + clientOptions: IFindRedisClientInstanceByOptions, + keys: string[], + ): Promise { + const [ + transactionError, + transactionResults, + ] = await this.redisConsumer.execPipeline(clientOptions, [ + // HACK: for OSS CLUSTER, for some reason, if the pipeline contains only 'MEMORY USAGE' commands + // IORedis.Cluster sometimes incorrectly determines for which node it is necessary to execute it. + // To fix it we insert one TTL command (with the key that belongs to the required node) + // at the head of the pipeline. + // And late we remove the result for TTL command and returns only results for 'MEMORY USAGE' + [BrowserToolKeysCommands.Ttl, keys[0]], + ...keys.map<[toolCommand: any, ...args: Array]>( + (key: string) => [ + BrowserToolKeysCommands.MemoryUsage, + key, + 'samples', + '0', + ], + ), + ]); + if (transactionError) { + throw transactionError; + } else { + // Remove the result for TTL command and returns only results for 'MEMORY USAGE' + transactionResults.shift(); + return transactionResults.map((item: [ReplyError, any]) => item[1]); + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.spec.ts new file mode 100644 index 0000000000..57e3b3a72c --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.spec.ts @@ -0,0 +1,1015 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisClusterConsumer, + mockRedisNoPermError, + mockSettingsJSON, + mockSettingsProvider, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { ReplyError } from 'src/models'; +import config from 'src/utils/config'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { GetKeysDto, RedisDataType } from 'src/modules/browser/dto'; +import { + BrowserToolClusterService, +} from 'src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { IGetNodeKeysResult } from 'src/modules/browser/services/keys-business/scanner/scanner.interface'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { ClusterStrategy } from './cluster.strategy'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; +const getKeyInfoResponse = { + name: 'testString', + type: 'string', + ttl: -1, + size: 50, +}; +const mockNodeEmptyResult: IGetNodeKeysResult = { + total: 0, + scanned: 0, + cursor: 0, + keys: [], +}; +const mockClusterNodes = [ + { host: '172.1.0.1', port: 7000 }, + { host: '172.1.0.1', port: 7001 }, + { host: '172.1.0.1', port: 7002 }, +]; +const mockGetClusterNodes = [ + { options: { ...mockClusterNodes[0] } }, + { options: { ...mockClusterNodes[1] } }, + { options: { ...mockClusterNodes[2] } }, +]; +const mockClusterNodesEmptyResult: IGetNodeKeysResult[] = [ + { ...mockNodeEmptyResult, ...mockClusterNodes[0] }, + { ...mockNodeEmptyResult, ...mockClusterNodes[1] }, + { ...mockNodeEmptyResult, ...mockClusterNodes[2] }, +]; + +const mockGetKeysInfoFn = jest.fn(); +mockGetKeysInfoFn.mockImplementation(async (clientOptions, keys) => { + if (keys.length < 1) { + return []; + } + return new Array(keys.length).fill(getKeyInfoResponse); +}); + +let strategy; +let browserTool; +let settingsProvider; + +describe('Cluster Scanner Strategy', () => { + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolClusterService, + useFactory: mockRedisClusterConsumer, + }, + { + provide: 'SETTINGS_PROVIDER', + useFactory: mockSettingsProvider, + }, + ], + }).compile(); + + browserTool = module.get( + BrowserToolClusterService, + ); + settingsProvider = module.get('SETTINGS_PROVIDER'); + settingsProvider.getSettings = jest.fn().mockResolvedValue({ + ...mockSettingsJSON, + scanThreshold: REDIS_SCAN_CONFIG.countThreshold, + }); + strategy = new ClusterStrategy(browserTool, settingsProvider); + mockGetKeysInfoFn.mockClear(); + }); + + describe('getKeys', () => { + beforeEach(() => { + browserTool.getNodes = jest.fn().mockResolvedValue(mockGetClusterNodes); + }); + const getKeysDto: GetKeysDto = { cursor: '0', count: 15 }; + it('should return appropriate value with filter by type', async () => { + const args = { ...getKeysDto, type: 'string', match: 'pattern*' }; + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + expect.anything(), + expect.anything(), + ) + .mockResolvedValue({ result: [0, [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + expect.anything(), + ) + .mockResolvedValue({ result: 1 }); + + strategy.getKeysInfo = jest.fn().mockResolvedValue([getKeyInfoResponse]); + + const result = await strategy.getKeys(mockClientOptions, args); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total: 1, + scanned: getKeysDto.count, + keys: [getKeyInfoResponse], + }, + { + ...mockClusterNodesEmptyResult[1], + total: 1, + scanned: getKeysDto.count, + keys: [getKeyInfoResponse], + }, + { + ...mockClusterNodesEmptyResult[2], + total: 1, + scanned: getKeysDto.count, + keys: [getKeyInfoResponse], + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalled(); + expect(browserTool.execCommandFromNode).toBeCalledTimes(6); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 2, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 3, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[2], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 4, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', args.match, 'COUNT', args.count, 'TYPE', args.type], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 5, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', args.match, 'COUNT', args.count, 'TYPE', args.type], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 6, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', args.match, 'COUNT', args.count, 'TYPE', args.type], + mockClusterNodes[2], + ); + }); + it('should call scan 3,2,1 times per nodes and return appropriate value', async () => { + const args = { ...getKeysDto }; + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[0], + ) + .mockResolvedValue({ result: 3000 }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ) + .mockResolvedValue({ result: ['1', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ) + .mockResolvedValue({ result: ['2', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['2', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ) + .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[1], + ) + .mockResolvedValue({ result: 2000 }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[1], + ) + .mockResolvedValue({ result: ['1', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[1], + ) + .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[2], + ) + .mockResolvedValue({ result: 1000 }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + expect.anything(), + mockClusterNodes[2], + ) + .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + + strategy.getKeysInfo = mockGetKeysInfoFn; + + const result = await strategy.getKeys(mockClientOptions, args); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total: 3000, + scanned: getKeysDto.count * 3, + keys: new Array(3).fill(getKeyInfoResponse), + }, + { + ...mockClusterNodesEmptyResult[1], + total: 2000, + scanned: getKeysDto.count * 2, + keys: new Array(2).fill(getKeyInfoResponse), + }, + { + ...mockClusterNodesEmptyResult[2], + total: 1000, + scanned: getKeysDto.count, + keys: [getKeyInfoResponse], + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalled(); + expect(browserTool.execCommandFromNode).toBeCalledTimes(9); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 2, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 3, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[2], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 4, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 5, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 6, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[2], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 7, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 8, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 9, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['2', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ); + }); + it('should call scan 3,2,N times per nodes until threshold exceeds', async () => { + const args = { ...getKeysDto, count: 100 }; + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[0], + ) + .mockResolvedValue({ result: 3000 }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ) + .mockResolvedValue({ result: ['1', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ) + .mockResolvedValue({ result: ['2', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['2', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ) + .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[1], + ) + .mockResolvedValue({ result: 2000 }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[1], + ) + .mockResolvedValue({ result: ['1', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[1], + ) + .mockResolvedValue({ result: ['0', [getKeyInfoResponse.name]] }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[2], + ) + .mockResolvedValue({ result: 1000000 }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + expect.anything(), + mockClusterNodes[2], + ) + .mockResolvedValue({ result: ['1', []] }); + + strategy.getKeysInfo = mockGetKeysInfoFn; + + const result = await strategy.getKeys(mockClientOptions, args); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total: 3000, + scanned: args.count * 3, + keys: new Array(3).fill(getKeyInfoResponse), + }, + { + ...mockClusterNodesEmptyResult[1], + total: 2000, + scanned: args.count * 2, + keys: new Array(2).fill(getKeyInfoResponse), + }, + { + ...mockClusterNodesEmptyResult[2], + total: 1000000, + cursor: 1, + scanned: + Math.trunc(REDIS_SCAN_CONFIG.countThreshold / args.count) + * args.count + - 5 * args.count, // 5 = scan for other shards (3 and 2) + keys: [], + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalled(); + expect(browserTool.execCommandFromNode).toBeCalledTimes( + Math.trunc(REDIS_SCAN_CONFIG.countThreshold / args.count) + 3, + ); // 3 = DB keys calls + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 2, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 3, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[2], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 4, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 5, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 6, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[2], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 7, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 8, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 9, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[2], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 10, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['2', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 11, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[2], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 12, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', args.count], + mockClusterNodes[2], + ); + }); + it('should not call scan when total is 0', async () => { + const args = { ...getKeysDto, count: undefined }; + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + expect.anything(), + ) + .mockResolvedValue({ result: 0 }); + + strategy.getKeysInfo = mockGetKeysInfoFn; + + const result = await strategy.getKeys(mockClientOptions, args); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total: 0, + scanned: 0, + keys: [], + }, + { + ...mockClusterNodesEmptyResult[1], + total: 0, + scanned: 0, + keys: [], + }, + { + ...mockClusterNodesEmptyResult[2], + total: 0, + scanned: 0, + keys: [], + }, + ]); + expect(strategy.getKeysInfo).toBeCalledTimes(0); + expect(browserTool.execCommandFromNode).toBeCalledTimes(3); // 3 = DB keys calls + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 2, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 3, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[2], + ); + }); + it('should work with custom cursor', async () => { + const args = { + ...getKeysDto, + cursor: '172.1.0.1:7000@0||172.1.0.1:7001@0||172.1.0.1:7002@0', + }; + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + expect.anything(), + ) + .mockResolvedValue({ result: 0 }); + + strategy.getKeysInfo = mockGetKeysInfoFn; + + const result = await strategy.getKeys(mockClientOptions, args); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total: 0, + scanned: 0, + keys: [], + }, + { + ...mockClusterNodesEmptyResult[1], + total: 0, + scanned: 0, + keys: [], + }, + { + ...mockClusterNodesEmptyResult[2], + total: 0, + scanned: 0, + keys: [], + }, + ]); + expect(strategy.getKeysInfo).toBeCalledTimes(0); + expect(browserTool.execCommandFromNode).toBeCalledTimes(3); // 3 = DB keys calls + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[0], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 2, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[1], + ); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 3, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[2], + ); + }); + it('should skip nodes with negative cursors custom cursor', async () => { + const args = { + ...getKeysDto, + cursor: '172.1.0.1:7000@0||172.1.0.1:7001@-1||172.1.0.1:7002@-22', + }; + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + expect.anything(), + ) + .mockResolvedValue({ result: 0 }); + + strategy.getKeysInfo = mockGetKeysInfoFn; + + const result = await strategy.getKeys(mockClientOptions, args); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total: 0, + scanned: 0, + keys: [], + }, + ]); + expect(strategy.getKeysInfo).toBeCalledTimes(0); + expect(browserTool.execCommandFromNode).toBeCalledTimes(1); + expect(browserTool.execCommandFromNode).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + mockClusterNodes[0], + ); + }); + it('should throw error if incorrect cursor passed', async () => { + try { + const args = { + ...getKeysDto, + cursor: '172.1.0.1asd00@0||172.1.0.1:7001@0||172.1.0.1:7002@0', + }; + await strategy.getKeys(mockClientOptions, args); + fail(); + } catch (err) { + expect(err.message).toEqual( + ERROR_MESSAGES.INCORRECT_CLUSTER_CURSOR_FORMAT, + ); + } + }); + it('should throw error on dbsize command', async () => { + const args = { ...getKeysDto }; + + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'DBSIZE', + }; + + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + expect.anything(), + ) + .mockRejectedValue(replyError); + + try { + await strategy.getKeys(mockClientOptions, args); + fail(); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should throw error on scan command', async () => { + const args = { ...getKeysDto }; + + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'SCAN', + }; + + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + expect.anything(), + ) + .mockResolvedValue({ result: 1 }); + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + expect.anything(), + expect.anything(), + ) + .mockRejectedValue(replyError); + + try { + await strategy.getKeys(mockClientOptions, args); + fail(); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + describe('get keys by glob patter', () => { + beforeEach(async () => { + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + expect.anything(), + ) + .mockResolvedValue({ result: 10 }); + strategy.scanNodes = jest.fn(); + }); + it("should call scan when math contains '?' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 'test?tring' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scanNodes).toHaveBeenCalled(); + }); + it("should call scan when math contains '*' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 'test*' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scanNodes).toHaveBeenCalled(); + }); + it("should call scan when math contains '[ae]' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 't[ae]stString' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scanNodes).toHaveBeenCalled(); + }); + it("should call scan when math contains '[a-e]' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 't[a-e]stString' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scanNodes).toHaveBeenCalled(); + }); + it("should call scan when math contains '[^e]' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 't[^e]stString' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scanNodes).toHaveBeenCalled(); + }); + it('should not call scan when math contains escaped glob', async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 't\\[a-e\\]stString' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scanNodes).not.toHaveBeenCalled(); + }); + }); + describe('find exact key', () => { + const key = getKeyInfoResponse.name; + const total = 10; + beforeEach(async () => { + when(browserTool.execCommandFromNode) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + [], + expect.anything(), + ) + .mockResolvedValue({ result: total }); + strategy.scanNodes = jest.fn(); + }); + it('should find exact key when match is not glob patter', async () => { + const dto: GetKeysDto = { ...getKeysDto, match: key }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total, + scanned: total, + keys: [getKeyInfoResponse], + }, + { + ...mockClusterNodesEmptyResult[1], + total, + scanned: total, + }, + { + ...mockClusterNodesEmptyResult[2], + total, + scanned: total, + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalledWith(mockClientOptions, [ + key, + ]); + expect(strategy.scanNodes).not.toHaveBeenCalled(); + }); + it('should find exact key when match is escaped glob patter', async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 'testString\\*' }; + const searchPattern = 'testString*'; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([{ ...getKeyInfoResponse, name: searchPattern }]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total, + scanned: total, + keys: [{ ...getKeyInfoResponse, name: searchPattern }], + }, + { + ...mockClusterNodesEmptyResult[1], + total, + scanned: total, + }, + { + ...mockClusterNodesEmptyResult[2], + total, + scanned: total, + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalledWith(mockClientOptions, [searchPattern]); + expect(strategy.scanNodes).not.toHaveBeenCalled(); + }); + it('should find exact key with correct type', async () => { + const dto: GetKeysDto = { + ...getKeysDto, + match: key, + type: RedisDataType.String, + }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total, + scanned: total, + keys: [getKeyInfoResponse], + }, + { + ...mockClusterNodesEmptyResult[1], + total, + scanned: total, + }, + { + ...mockClusterNodesEmptyResult[2], + total, + scanned: total, + }, + ]); + }); + it('should return empty array if key not exist', async () => { + const dto: GetKeysDto = { ...getKeysDto, match: key }; + strategy.getKeysInfo = jest.fn().mockResolvedValue([ + { + name: 'testString', + type: 'none', + ttl: -2, + size: null, + }, + ]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total, + scanned: total, + keys: [], + }, + { + ...mockClusterNodesEmptyResult[1], + total, + scanned: total, + }, + { + ...mockClusterNodesEmptyResult[2], + total, + scanned: total, + }, + ]); + }); + it('should return empty array if key has wrong type', async () => { + const dto: GetKeysDto = { + ...getKeysDto, + match: key, + type: RedisDataType.Hash, + }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockClusterNodesEmptyResult[0], + total, + scanned: total, + keys: [], + }, + { + ...mockClusterNodesEmptyResult[1], + total, + scanned: total, + }, + { + ...mockClusterNodesEmptyResult[2], + total, + scanned: total, + }, + ]); + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.ts new file mode 100644 index 0000000000..ec568c0b93 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.ts @@ -0,0 +1,207 @@ +import { toNumber } from 'lodash'; +import * as isGlob from 'is-glob'; +import config from 'src/utils/config'; +import { unescapeGlob } from 'src/utils'; +import { + BrowserToolClusterService, +} from 'src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { + GetKeyInfoResponse, + GetKeysWithDetailsResponse, + RedisDataType, +} from 'src/modules/browser/dto'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { AbstractStrategy } from './abstract.strategy'; +import { IGetNodeKeysResult } from '../scanner.interface'; + +const NODES_SEPARATOR = '||'; +const CURSOR_SEPARATOR = '@'; +// Correct format 172.17.0.1:7001@-1||172.17.0.1:7002@33 +const CLUSTER_CURSOR_REGEX = /^(([a-z0-9.])+:[0-9]+(@-?\d+))+((\|\|)?([a-z0-9.])+:[0-9]+(@-?\d+))*$/; +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +export class ClusterStrategy extends AbstractStrategy { + private readonly redisManager: BrowserToolClusterService; + + private settingsProvider: ISettingsProvider; + + constructor( + redisManager: BrowserToolClusterService, + settingsProvider: ISettingsProvider, + ) { + super(redisManager); + this.redisManager = redisManager; + this.settingsProvider = settingsProvider; + } + + public async getKeys( + clientOptions, + args, + ): Promise { + const match = args.match !== undefined ? args.match : '*'; + const count = args.count || REDIS_SCAN_CONFIG.countDefault; + const nodes = await this.getNodesToScan(clientOptions, args.cursor); + const settings = await this.settingsProvider.getSettings(); + await this.calculateNodesTotalKeys(clientOptions, nodes); + + if (!isGlob(match, { strict: false })) { + const keyName = unescapeGlob(match); + nodes.forEach((node) => { + // eslint-disable-next-line no-param-reassign + node.cursor = 0; + // eslint-disable-next-line no-param-reassign + node.scanned = node.total; + }); + nodes[0].keys = await this.getKeysInfo(clientOptions, [keyName]); + nodes[0].keys = nodes[0].keys.filter((key: GetKeyInfoResponse) => { + if (key.ttl === -2) { + return false; + } + if (args.type) { + return key.type === args.type; + } + return true; + }); + return nodes; + } + + let allNodesScanned = false; + while ( + !allNodesScanned + && nodes.reduce((prev, cur) => prev + cur.keys.length, 0) < count + && nodes.reduce((prev, cur) => prev + cur.scanned, 0) + < settings.scanThreshold + ) { + await this.scanNodes(clientOptions, nodes, match, count, args.type); + allNodesScanned = !nodes.some((node) => node.cursor !== 0); + } + + await Promise.all( + nodes.map(async (node) => { + if (node.keys.length) { + // eslint-disable-next-line no-param-reassign + node.keys = await this.getKeysInfo( + clientOptions, + node.keys, + args.type, + ); + } + }), + ); + + return nodes; + } + + private async getNodesToScan( + clientOptions: IFindRedisClientInstanceByOptions, + initialCursor: string, + ): Promise { + if (Number.isNaN(toNumber(initialCursor))) { + return this.getNodesFromClusterCursor(initialCursor); + } + + const clusterNodes = await this.redisManager.getNodes( + clientOptions, + 'master', + ); + + return clusterNodes.map(({ options: { host, port } }) => ({ + host, + port, + cursor: 0, + keys: [], + total: 0, + scanned: 0, + })); + } + + /** + * Parses composed custom cursor from FE and returns nodes + * Format: 172.17.0.1:7001@22||172.17.0.1:7002@33 + */ + private getNodesFromClusterCursor(cursor: string): IGetNodeKeysResult[] { + const isCorrectFormat = CLUSTER_CURSOR_REGEX.test(cursor); + if (!isCorrectFormat) { + throw new Error(ERROR_MESSAGES.INCORRECT_CLUSTER_CURSOR_FORMAT); + } + const nodeStrings = cursor.split(NODES_SEPARATOR); + const nodes = []; + + nodeStrings.forEach((item: string) => { + const [address, nextCursor] = item.split(CURSOR_SEPARATOR); + const [host, port] = address.split(':'); + if (parseInt(nextCursor, 10) >= 0) { + nodes.push({ + total: 0, + scanned: 0, + host, + port: parseInt(port, 10), + cursor: parseInt(nextCursor, 10), + keys: [], + }); + } + }); + return nodes; + } + + private async calculateNodesTotalKeys( + clientOptions, + nodes: IGetNodeKeysResult[], + ): Promise { + await Promise.all( + nodes.map(async (node) => { + const result = await this.redisManager.execCommandFromNode( + clientOptions, + BrowserToolKeysCommands.DbSize, + [], + { host: node.host, port: node.port }, + ); + // eslint-disable-next-line no-param-reassign + node.total = result.result; + }), + ); + } + + /** + * Scan keys for each node and mutates input data + */ + private async scanNodes( + clientOptions, + nodes: IGetNodeKeysResult[], + match: string, + count: number, + type?: RedisDataType, + ): Promise { + await Promise.all( + nodes.map(async (node) => { + // ignore full scanned nodes or nodes with no items + if ((node.cursor === 0 && node.scanned !== 0) || node.total === 0) { + return; + } + + const commandArgs = [`${node.cursor}`, 'MATCH', match, 'COUNT', count]; + if (type) { + commandArgs.push('TYPE', type); + } + + const { + result, + } = await this.redisManager.execCommandFromNode( + clientOptions, + BrowserToolKeysCommands.Scan, + commandArgs, + { host: node.host, port: node.port }, + ); + + // eslint-disable-next-line no-param-reassign + node.cursor = parseInt(result[0], 10); + node.keys.push(...result[1]); + // eslint-disable-next-line no-param-reassign + node.scanned += count; + }), + ); + } +} diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts new file mode 100644 index 0000000000..e36459b083 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts @@ -0,0 +1,509 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockSettingsJSON, + mockSettingsProvider, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { ReplyError } from 'src/models'; +import config from 'src/utils/config'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { GetKeysDto, RedisDataType } from 'src/modules/browser/dto'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { IGetNodeKeysResult } from 'src/modules/browser/services/keys-business/scanner/scanner.interface'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { StandaloneStrategy } from './standalone.strategy'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; +const getKeyInfoResponse = { + name: 'testString', + type: 'string', + ttl: -1, + size: 50, +}; +const mockNodeEmptyResult: IGetNodeKeysResult = { + total: 0, + scanned: 0, + cursor: 0, + keys: [], +}; + +let strategy; +let browserTool; +let settingsProvider; + +describe('Standalone Scanner Strategy', () => { + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + { + provide: 'SETTINGS_PROVIDER', + useFactory: mockSettingsProvider, + }, + ], + }).compile(); + + browserTool = module.get(BrowserToolService); + settingsProvider = module.get('SETTINGS_PROVIDER'); + settingsProvider.getSettings = jest.fn().mockResolvedValue({ + ...mockSettingsJSON, + scanThreshold: REDIS_SCAN_CONFIG.countThreshold, + }); + strategy = new StandaloneStrategy(browserTool, settingsProvider); + }); + describe('getKeys', () => { + const getKeysDto: GetKeysDto = { cursor: '0', count: 15 }; + it('should return appropriate value with filter by type', async () => { + const args = { ...getKeysDto, type: 'string', match: 'pattern*' }; + + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + expect.anything(), + ) + .mockResolvedValue([0, [getKeyInfoResponse.name]]); + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ) + .mockResolvedValue(1); + + strategy.getKeysInfo = jest.fn().mockResolvedValue([getKeyInfoResponse]); + + const result = await strategy.getKeys(mockClientOptions, args); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + total: 1, + scanned: getKeysDto.count, + keys: [getKeyInfoResponse], + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalled(); + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 2, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', args.match, 'COUNT', args.count, 'TYPE', args.type], + ); + }); + it('should call scan 3 times and return appropriate value', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Scan, [ + '0', + 'MATCH', + '*', + 'COUNT', + getKeysDto.count, + ]) + .mockResolvedValue(['1', new Array(3).fill(getKeyInfoResponse.name)]); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Scan, [ + '1', + 'MATCH', + '*', + 'COUNT', + getKeysDto.count, + ]) + .mockResolvedValue(['2', new Array(3).fill(getKeyInfoResponse.name)]); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Scan, [ + '2', + 'MATCH', + '*', + 'COUNT', + getKeysDto.count, + ]) + .mockResolvedValue(['0', new Array(3).fill(getKeyInfoResponse.name)]); + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ) + .mockResolvedValue(1000000); + + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue(new Array(9).fill(getKeyInfoResponse)); + + const result = await strategy.getKeys(mockClientOptions, getKeysDto); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + total: 1000000, + scanned: getKeysDto.count * 3, + keys: new Array(9).fill(getKeyInfoResponse), + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalled(); + expect(browserTool.execCommand).toBeCalledTimes(4); + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ); + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 2, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['0', 'MATCH', '*', 'COUNT', getKeysDto.count], + ); + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 3, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['1', 'MATCH', '*', 'COUNT', getKeysDto.count], + ); + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 4, + mockClientOptions, + BrowserToolKeysCommands.Scan, + ['2', 'MATCH', '*', 'COUNT', getKeysDto.count], + ); + }); + it('should call scan N times until threshold exceeds', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + expect.anything(), + ) + .mockResolvedValue(['1', []]); + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ) + .mockResolvedValue(1000000); + + strategy.getKeysInfo = jest.fn().mockResolvedValue([]); + + const result = await strategy.getKeys(mockClientOptions, getKeysDto); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + cursor: 1, + total: 1000000, + scanned: + Math.trunc(REDIS_SCAN_CONFIG.countThreshold / getKeysDto.count) + * getKeysDto.count + + getKeysDto.count, + keys: [], + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalledTimes(0); + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ); + }); + it('should not call scan when total is 0', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ) + .mockResolvedValue(0); + + strategy.getKeysInfo = jest.fn().mockResolvedValue([]); + + const result = await strategy.getKeys(mockClientOptions, getKeysDto); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + }, + ]); + expect(browserTool.execCommand).toBeCalledTimes(1); + expect(browserTool.execCommand).toHaveBeenLastCalledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ); + expect(strategy.getKeysInfo).toBeCalledTimes(0); + }); + it('should call scan with required args', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ) + .mockResolvedValue(0); + strategy.getKeysInfo = jest.fn().mockResolvedValue([]); + strategy.scan = jest.fn().mockResolvedValue(undefined); + + const result = await strategy.getKeys(mockClientOptions, { + cursor: '0', + type: RedisDataType.String, + }); + + expect(strategy.scan).toHaveBeenLastCalledWith( + mockClientOptions, + mockNodeEmptyResult, + '*', + REDIS_SCAN_CONFIG.countDefault, + RedisDataType.String, + ); + expect(result).toEqual([mockNodeEmptyResult]); + expect(strategy.getKeysInfo).toBeCalledTimes(0); + }); + it('should throw error on dbsize command', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'DBSIZE', + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.DbSize, []) + .mockRejectedValue(replyError); + + try { + await strategy.getKeys(mockClientOptions, getKeysDto); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + it('should throw error on scan command', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ) + .mockResolvedValue(10); + + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'SCAN', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Scan, + expect.anything(), + ) + .mockRejectedValue(replyError); + + try { + await strategy.getKeys(mockClientOptions, getKeysDto); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + describe('get keys by glob patter', () => { + beforeEach(async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ) + .mockResolvedValue(10); + strategy.scan = jest.fn(); + }); + it("should call scan when math contains '?' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 'test?tring' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scan).toHaveBeenCalled(); + }); + it("should call scan when math contains '*' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 'test*' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scan).toHaveBeenCalled(); + }); + it("should call scan when math contains '[ae]' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 't[ae]stString' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scan).toHaveBeenCalled(); + }); + it("should call scan when math contains '[a-e]' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 't[a-e]stString' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scan).toHaveBeenCalled(); + }); + it("should call scan when math contains '[^e]' glob", async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 't[^e]stString' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scan).toHaveBeenCalled(); + }); + it('should not call scan when math contains escaped glob', async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 't\\[a-e\\]stString' }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + await strategy.getKeys(mockClientOptions, dto); + + expect(strategy.scan).not.toHaveBeenCalled(); + }); + }); + describe('find exact key', () => { + const key = getKeyInfoResponse.name; + const total = 10; + beforeEach(async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.DbSize, + expect.anything(), + ) + .mockResolvedValue(total); + strategy.scan = jest.fn(); + }); + it('should find exact key when match is not glob patter', async () => { + const dto: GetKeysDto = { ...getKeysDto, match: key }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + total, + scanned: total, + keys: [getKeyInfoResponse], + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalledWith(mockClientOptions, [ + key, + ]); + expect(strategy.scan).not.toHaveBeenCalled(); + }); + it('should find exact key when match is escaped glob patter', async () => { + const dto: GetKeysDto = { ...getKeysDto, match: 'testString\\*' }; + const mockSearchPattern = 'testString*'; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([{ ...getKeyInfoResponse, name: mockSearchPattern }]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + total, + scanned: total, + keys: [{ ...getKeyInfoResponse, name: mockSearchPattern }], + }, + ]); + expect(strategy.getKeysInfo).toHaveBeenCalledWith(mockClientOptions, [mockSearchPattern]); + expect(strategy.scan).not.toHaveBeenCalled(); + }); + it('should find exact key with correct type', async () => { + const dto: GetKeysDto = { + ...getKeysDto, + match: key, + type: RedisDataType.String, + }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + total, + scanned: total, + keys: [getKeyInfoResponse], + }, + ]); + }); + it('should return empty array if key not exist', async () => { + const dto: GetKeysDto = { ...getKeysDto, match: key }; + strategy.getKeysInfo = jest.fn().mockResolvedValue([ + { + name: 'testString', + type: 'none', + ttl: -2, + size: null, + }, + ]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + total, + scanned: total, + keys: [], + }, + ]); + }); + it('should return empty array if key has wrong type', async () => { + const dto: GetKeysDto = { + ...getKeysDto, + match: key, + type: RedisDataType.Hash, + }; + strategy.getKeysInfo = jest + .fn() + .mockResolvedValue([getKeyInfoResponse]); + + const result = await strategy.getKeys(mockClientOptions, dto); + + expect(result).toEqual([ + { + ...mockNodeEmptyResult, + total, + scanned: total, + keys: [], + }, + ]); + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts new file mode 100644 index 0000000000..c9690bec5e --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts @@ -0,0 +1,107 @@ +import * as isGlob from 'is-glob'; +import config from 'src/utils/config'; +import { unescapeGlob } from 'src/utils'; +import { + GetKeyInfoResponse, + GetKeysWithDetailsResponse, + RedisDataType, +} from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { AbstractStrategy } from './abstract.strategy'; +import { IGetNodeKeysResult } from '../scanner.interface'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +export class StandaloneStrategy extends AbstractStrategy { + private readonly redisManager: BrowserToolService; + + private settingsProvider: ISettingsProvider; + + constructor( + redisManager: BrowserToolService, + settingsProvider: ISettingsProvider, + ) { + super(redisManager); + this.redisManager = redisManager; + this.settingsProvider = settingsProvider; + } + + public async getKeys( + clientOptions, + args, + ): Promise { + const match = args.match !== undefined ? args.match : '*'; + const count = args.count || REDIS_SCAN_CONFIG.countDefault; + const node = { + total: 0, + scanned: 0, + keys: [], + cursor: parseInt(args.cursor, 10), + }; + node.total = await this.redisManager.execCommand( + clientOptions, + BrowserToolKeysCommands.DbSize, + [], + ); + if (!isGlob(match, { strict: false })) { + const keyName = unescapeGlob(match); + node.cursor = 0; + node.scanned = node.total; + node.keys = await this.getKeysInfo(clientOptions, [keyName]); + node.keys = node.keys.filter((key: GetKeyInfoResponse) => { + if (key.ttl === -2) { + return false; + } + if (args.type) { + return key.type === args.type; + } + return true; + }); + return [node]; + } + + await this.scan(clientOptions, node, match, count, args.type); + if (node.keys.length) { + node.keys = await this.getKeysInfo(clientOptions, node.keys, args.type); + } + + return [node]; + } + + public async scan( + clientOptions, + node: IGetNodeKeysResult, + match: string, + count: number, + type?: RedisDataType, + ): Promise { + let fullScanned = false; + const settings = await this.settingsProvider.getSettings(); + while ( + node.total > 0 + && !fullScanned + && node.keys.length < count + && node.scanned < settings.scanThreshold + ) { + let commandArgs = [`${node.cursor}`, 'MATCH', match, 'COUNT', count]; + if (type) { + commandArgs = [...commandArgs, 'TYPE', type]; + } + const execResult = await this.redisManager.execCommand( + clientOptions, + BrowserToolKeysCommands.Scan, + [...commandArgs], + ); + + const [nextCursor, keys] = execResult; + // eslint-disable-next-line no-param-reassign + node.cursor = parseInt(nextCursor, 10); + // eslint-disable-next-line no-param-reassign + node.scanned += count; + node.keys.push(...keys); + fullScanned = node.cursor === 0; + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/list-business/list-business.service.spec.ts b/redisinsight/api/src/modules/browser/services/list-business/list-business.service.spec.ts new file mode 100644 index 0000000000..38da70f281 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/list-business/list-business.service.spec.ts @@ -0,0 +1,605 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { when } from 'jest-when'; +import { ReplyError } from 'src/models/redis-client'; +import { + mockBrowserAnalyticsService, + mockRedisConsumer, + mockRedisNoPermError, + mockRedisWrongNumberOfArgumentsError, + mockRedisWrongTypeError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { + CreateListWithExpireDto, + DeleteListElementsDto, + GetListElementResponse, + GetListElementsDto, + GetListElementsResponse, + KeyDto, + ListElementDestination, + PushElementToListDto, + SetListElementDto, +} from 'src/modules/browser/dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolKeysCommands, + BrowserToolListCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { ListBusinessService } from './list-business.service'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; +const mockKeyDto: KeyDto = { + keyName: 'testList', +}; +const mockIndex: number = 0; +const mockGetListElementResponse: GetListElementResponse = { + keyName: mockKeyDto.keyName, + value: 'somesortofstring', +}; +const mockPushElementDto: PushElementToListDto = { + keyName: 'testList', + element: 'Lorem ipsum dolor sit amet.', + destination: ListElementDestination.Tail, +}; +const mockGetListElementsDto: GetListElementsDto = { + keyName: 'testList', + offset: 0, + count: 10, +}; +const mockListElements: string[] = ['element']; + +const mockGetListElementsResponse: GetListElementsResponse = { + keyName: mockPushElementDto.keyName, + total: mockListElements.length, + elements: mockListElements, +}; + +const mockSetListElementDto: SetListElementDto = { + keyName: 'testList', + element: 'Lorem ipsum dolor sit amet.', + index: 0, +}; + +const mockDeleteElementsDto: DeleteListElementsDto = { + keyName: 'testList', + destination: ListElementDestination.Tail, + count: 1, +}; + +describe('ListBusinessService', () => { + let service: ListBusinessService; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ListBusinessService, + { + provide: BrowserAnalyticsService, + useFactory: mockBrowserAnalyticsService, + }, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(ListBusinessService); + browserTool = module.get(BrowserToolService); + }); + + describe('createList', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockPushElementDto.keyName, + ]) + .mockResolvedValue(false); + service.createListWithExpiration = jest.fn(); + }); + it('create list with expiration', async () => { + service.createListWithExpiration = jest + .fn() + .mockResolvedValue(undefined); + + await expect( + service.createList(mockClientOptions, { + ...mockPushElementDto, + expire: 1000, + }), + ).resolves.not.toThrow(); + expect(service.createListWithExpiration).toHaveBeenCalled(); + }); + it('create list without expiration', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.LPush, [ + mockPushElementDto.keyName, + mockPushElementDto.element, + ]) + .mockResolvedValue(1); + + await expect( + service.createList(mockClientOptions, mockPushElementDto), + ).resolves.not.toThrow(); + expect(service.createListWithExpiration).not.toHaveBeenCalled(); + }); + it('key with this name exist', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockPushElementDto.keyName, + ]) + .mockResolvedValue(true); + + await expect( + service.createList(mockClientOptions, mockPushElementDto), + ).rejects.toThrow(ConflictException); + expect(browserTool.execCommand).toHaveBeenCalledTimes(1); + expect(browserTool.execMulti).not.toHaveBeenCalled(); + }); + it("user don't have required permissions for createList", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'LPUSH', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.createList(mockClientOptions, mockPushElementDto), + ).rejects.toThrow(ForbiddenException); + expect(browserTool.execCommand).toHaveBeenCalledTimes(1); + expect(browserTool.execMulti).not.toHaveBeenCalled(); + }); + }); + + describe('pushElement', () => { + it('succeed to insert element at the tail of the list data type', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.RPushX, [ + mockPushElementDto.keyName, + mockPushElementDto.element, + ]) + .mockResolvedValue(1); + + await expect( + service.pushElement(mockClientOptions, mockPushElementDto), + ).resolves.not.toThrow(); + }); + it('succeed to insert element at the head of the list data type', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.LPushX, [ + mockPushElementDto.keyName, + mockPushElementDto.element, + ]) + .mockResolvedValue(12); + + const result = await service.pushElement(mockClientOptions, { + ...mockPushElementDto, + destination: ListElementDestination.Head, + }); + expect(result.keyName).toEqual(mockPushElementDto.keyName); + expect(result.total).toEqual(12); + }); + it('key with this name does not exist for pushElement', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.RPushX, [ + mockPushElementDto.keyName, + mockPushElementDto.element, + ]) + .mockResolvedValue(0); + + await expect( + service.pushElement(mockClientOptions, mockPushElementDto), + ).rejects.toThrow(NotFoundException); + }); + it("user don't have required permissions for pushElement", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'RPUSHX', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.pushElement(mockClientOptions, mockPushElementDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getElements', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.LLen, [ + mockPushElementDto.keyName, + ]) + .mockResolvedValue(mockListElements.length); + }); + it('succeed to get elements of the list', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolListCommands.Lrange, + expect.anything(), + ) + .mockResolvedValue(mockListElements); + + const result = await service.getElements( + mockClientOptions, + mockGetListElementsDto, + ); + await expect(result).toEqual(mockGetListElementsResponse); + }); + it('key with this name does not exist for getElements', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.LLen, [ + mockPushElementDto.keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.getElements(mockClientOptions, mockGetListElementsDto), + ).rejects.toThrow(NotFoundException); + }); + it("try to use 'LLEN' command not for list data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'LLEN', + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.LLen, [ + mockPushElementDto.keyName, + ]) + .mockRejectedValue(replyError); + + await expect( + service.getElements(mockClientOptions, mockGetListElementsDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for getElements", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'LRANGE', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.getElements(mockClientOptions, mockGetListElementsDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getElement', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockKeyDto.keyName, + ]) + .mockResolvedValue(1); + }); + it('try to use LINDEX command not for list data type', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'LINDEX', + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.LIndex, [ + mockKeyDto.keyName, + expect.anything(), + ]) + .mockRejectedValue(replyError); + + await expect( + service.getElement(mockClientOptions, mockIndex, mockKeyDto), + ).rejects.toThrow(BadRequestException); + }); + it("user hasn't permissions to LINDEX", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'LINDEX', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolListCommands.LIndex, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.getElement(mockClientOptions, mockIndex, mockKeyDto), + ).rejects.toThrow(ForbiddenException); + }); + it("user hasn't permissions to EXISTS", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'EXISTS', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolKeysCommands.Exists, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.getElement(mockClientOptions, mockIndex, mockKeyDto), + ).rejects.toThrow(ForbiddenException); + }); + it('key with this name does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockKeyDto.keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.getElement(mockClientOptions, mockIndex, mockKeyDto), + ).rejects.toThrow(NotFoundException); + }); + it('index is out of range', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolListCommands.LIndex, + expect.anything(), + ) + .mockResolvedValue(null); + + await expect( + service.getElement(mockClientOptions, mockIndex, mockKeyDto), + ).rejects.toThrow(NotFoundException); + }); + it('succeed to get List element by index', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolListCommands.LIndex, + expect.anything(), + ) + .mockResolvedValue(mockGetListElementResponse.value); + + const result = await service.getElement( + mockClientOptions, + mockIndex, + mockKeyDto, + ); + await expect(result).toEqual(mockGetListElementResponse); + }); + }); + + describe('setElement', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockSetListElementDto.keyName, + ]) + .mockResolvedValue(true); + }); + it('succeed to set the list element at index', async () => { + const { keyName, index, element } = mockSetListElementDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.LSet, [ + keyName, + index, + element, + ]) + .mockResolvedValue('OK'); + + await expect( + service.setElement(mockClientOptions, mockSetListElementDto), + ).resolves.not.toThrow(); + }); + it('key with this name does not exist for setElement', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockSetListElementDto.keyName, + ]) + .mockResolvedValue(false); + + await expect( + service.setElement(mockClientOptions, mockSetListElementDto), + ).rejects.toThrow(NotFoundException); + }); + it("try to use 'LSET' command not for list data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'LSET', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.setElement(mockClientOptions, mockSetListElementDto), + ).rejects.toThrow(BadRequestException); + }); + it('index for LSET coomand is of out of range', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: 'LSET', + message: 'ERR index out of range', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.setElement(mockClientOptions, mockSetListElementDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'LSET', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.setElement(mockClientOptions, mockSetListElementDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('deleteElements', () => { + it('succeed to remove element from the tail', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.RPop, [ + mockDeleteElementsDto.keyName, + ]) + .mockResolvedValue(mockListElements[0]); + + const result = await service.deleteElements( + mockClientOptions, + mockDeleteElementsDto, + ); + + await expect(result).toEqual({ elements: [mockListElements[0]] }); + }); + it('succeed to remove element from the head', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.LPop, [ + mockDeleteElementsDto.keyName, + ]) + .mockResolvedValue(mockListElements[0]); + + const result = await service.deleteElements(mockClientOptions, { + ...mockDeleteElementsDto, + destination: ListElementDestination.Head, + }); + + await expect(result).toEqual({ elements: [mockListElements[0]] }); + }); + it('succeed to remove multiple elements from the tail', async () => { + const mockDeletedElements = ['element1', 'element2']; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.RPop, [ + mockDeleteElementsDto.keyName, + 2, + ]) + .mockResolvedValue(mockDeletedElements); + + const result = await service.deleteElements(mockClientOptions, { + ...mockDeleteElementsDto, + count: 2, + }); + await expect(result).toEqual({ elements: mockDeletedElements }); + }); + it('try to use RPOP command not for list data type', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'RPOP', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolListCommands.RPop, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.deleteElements(mockClientOptions, mockDeleteElementsDto), + ).rejects.toThrow(BadRequestException); + }); + it("redis doesn't support 'RPOP' with 'count' argument", async () => { + const replyError: ReplyError = { + ...mockRedisWrongNumberOfArgumentsError, + command: { + name: 'rpop', + args: [mockDeleteElementsDto.keyName, 2], + }, + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.RPop, [ + mockDeleteElementsDto.keyName, + 2, + ]) + .mockRejectedValue(replyError); + + await expect( + service.deleteElements(mockClientOptions, { + ...mockDeleteElementsDto, + count: 2, + }), + ).rejects.toThrow(BadRequestException); + }); + it("user hasn't permissions to RPOP", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'RPOP', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolListCommands.RPop, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.deleteElements(mockClientOptions, mockDeleteElementsDto), + ).rejects.toThrow(ForbiddenException); + }); + it('key with this name does not exists', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolListCommands.RPop, [ + mockDeleteElementsDto.keyName, + ]) + .mockResolvedValue(null); + + await expect( + service.deleteElements(mockClientOptions, mockDeleteElementsDto), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('_createListWithExpiration', () => { + const dto: CreateListWithExpireDto = { + ...mockPushElementDto, + expire: 1000, + }; + it("shouldn't throw error", async () => { + when(browserTool.execMulti) + .calledWith(mockClientOptions, [ + [BrowserToolListCommands.LPush, dto.keyName, dto.element], + [BrowserToolKeysCommands.Expire, dto.keyName, dto.expire], + ]) + .mockResolvedValue([ + null, + [ + [null, 1], + [null, 1], + ], + ]); + + await expect( + service.createListWithExpiration(mockClientOptions, dto), + ).resolves.not.toThrow(); + }); + it('should throw error', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'LPUSH', + }; + when(browserTool.execMulti) + .calledWith(mockClientOptions, [ + [BrowserToolListCommands.LPush, dto.keyName, dto.element], + [BrowserToolKeysCommands.Expire, dto.keyName, dto.expire], + ]) + .mockResolvedValue([replyError, []]); + + try { + await service.createListWithExpiration(mockClientOptions, dto); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual(replyError.message); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/list-business/list-business.service.ts b/redisinsight/api/src/modules/browser/services/list-business/list-business.service.ts new file mode 100644 index 0000000000..2b4e252843 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/list-business/list-business.service.ts @@ -0,0 +1,347 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { isNull, isArray } from 'lodash'; +import { RedisErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { catchAclError, catchTransactionError } from 'src/utils'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { + CreateListWithExpireDto, + DeleteListElementsDto, + DeleteListElementsResponse, + GetListElementResponse, + GetListElementsDto, + GetListElementsResponse, + KeyDto, + ListElementDestination, + PushElementToListDto, + PushListElementsResponse, + RedisDataType, + SetListElementDto, + SetListElementResponse, +} from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolListCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { BrowserToolService } from '../browser-tool/browser-tool.service'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +@Injectable() +export class ListBusinessService { + private logger = new Logger('ListBusinessService'); + + constructor( + private browserTool: BrowserToolService, + private browserAnalyticsService: BrowserAnalyticsService, + ) {} + + public async createList( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateListWithExpireDto, + ): Promise { + this.logger.log('Creating list data type.'); + const { keyName } = dto; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (isExist) { + this.logger.error( + `Failed to create list data type. ${ERROR_MESSAGES.KEY_NAME_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new ConflictException(ERROR_MESSAGES.KEY_NAME_EXIST), + ); + } + if (dto.expire) { + await this.createListWithExpiration(clientOptions, dto); + } else { + await this.createSimpleList(clientOptions, dto); + } + this.browserAnalyticsService.sendKeyAddedEvent( + clientOptions.instanceId, + RedisDataType.List, + { + length: 1, + TTL: dto.expire || -1, + }, + ); + this.logger.log('Succeed to create list data type.'); + } catch (error) { + this.logger.error('Failed to create list data type.', error); + catchAclError(error); + } + return null; + } + + public async pushElement( + clientOptions: IFindRedisClientInstanceByOptions, + dto: PushElementToListDto, + ): Promise { + this.logger.log('Insert element at the tail/head of the list data type.'); + const { keyName, element, destination } = dto; + try { + const total = await this.browserTool.execCommand( + clientOptions, + destination === ListElementDestination.Tail + ? BrowserToolListCommands.RPushX + : BrowserToolListCommands.LPushX, + [keyName, element], + ); + if (!total) { + this.logger.error( + `Failed to inserts element at the ${destination} of the list data type. Key not found. key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + this.browserAnalyticsService.sendKeyValueAddedEvent( + clientOptions.instanceId, + RedisDataType.List, + { + numberOfAdded: 1, + }, + ); + this.logger.log( + `Succeed to insert element at the ${destination} of the list data type.`, + ); + return { keyName, total }; + } catch (error) { + this.logger.error('Failed to inserts element to the list data type.', error); + if (error.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async getElements( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetListElementsDto, + ): Promise { + this.logger.log('Getting elements of the list stored at key.'); + const { keyName, offset, count } = dto; + let result: GetListElementsResponse; + try { + const total = await this.browserTool.execCommand( + clientOptions, + BrowserToolListCommands.LLen, + [keyName], + ); + if (!total) { + this.logger.error( + `Failed to get elements of the list. Key not found. key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + const elements = await this.browserTool.execCommand( + clientOptions, + BrowserToolListCommands.Lrange, + [keyName, offset, offset + count - 1], + ); + this.logger.log('Succeed to get elements of the list.'); + result = { keyName, total, elements }; + } catch (error) { + this.logger.error('Failed to to get elements of the list.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + return result; + } + + /** + * Get List element by index + * NotFound exception when redis return null + * @param clientOptions + * @param index + * @param dto + */ + public async getElement( + clientOptions: IFindRedisClientInstanceByOptions, + index: number, + dto: KeyDto, + ): Promise { + this.logger.log('Getting List element by index.'); + const { keyName } = dto; + try { + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + + if (!exists) { + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + + const value = await this.browserTool.execCommand( + clientOptions, + BrowserToolListCommands.LIndex, + [keyName, index], + ); + + if (value === null) { + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.INDEX_OUT_OF_RANGE()), + ); + } + this.browserAnalyticsService.sendGetListElementByIndexEvent( + clientOptions.instanceId, + ); + this.logger.log('Succeed to get List element by index.'); + return { keyName, value }; + } catch (error) { + this.logger.error('Failed to to get List element by index.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async setElement( + clientOptions: IFindRedisClientInstanceByOptions, + dto: SetListElementDto, + ): Promise { + this.logger.log('Setting the list element at index'); + const { keyName, element, index } = dto; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to set the list element at index. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + await this.browserTool.execCommand( + clientOptions, + BrowserToolListCommands.LSet, + [keyName, index, element], + ); + this.browserAnalyticsService.sendKeyValueEditedEvent( + clientOptions.instanceId, + RedisDataType.List, + ); + this.logger.log('Succeed to set the list element at index.'); + } catch (error) { + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + if (error?.message.includes('index out of range')) { + throw new BadRequestException(error.message); + } + this.logger.error('Failed to set the list element at index.', error); + catchAclError(error); + } + return { index, element }; + } + + /** + * Delete and return the elements from the tail/head of list stored at key + * NotFound exception when redis return null + * @param clientOptions + * @param dto + */ + public async deleteElements( + clientOptions: IFindRedisClientInstanceByOptions, + dto: DeleteListElementsDto, + ): Promise { + this.logger.log('Deleting elements from the list stored at key.'); + const { keyName, count, destination } = dto; + try { + const execArgs = !!count && count > 1 ? [keyName, count] : [keyName]; + let result; + if (destination === ListElementDestination.Head) { + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolListCommands.LPop, + execArgs, + ); + } else { + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolListCommands.RPop, + execArgs, + ); + } + if (isNull(result)) { + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + this.browserAnalyticsService.sendKeyValueRemovedEvent( + clientOptions.instanceId, + RedisDataType.List, + { + numberOfRemoved: isArray(result) ? result.length : 1, + }, + ); + return { + elements: isArray(result) ? [...result] : [result], + }; + } catch (error) { + this.logger.error('Failed to delete elements from the list stored at key.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + if ( + error?.message.includes('wrong number of arguments') + && error?.command?.args?.length === 2 + ) { + throw new BadRequestException( + ERROR_MESSAGES.REMOVING_MULTIPLE_ELEMENTS_NOT_SUPPORT(), + ); + } + throw catchAclError(error); + } + } + + public async createSimpleList( + clientOptions: IFindRedisClientInstanceByOptions, + dto: PushElementToListDto, + ): Promise { + const { keyName, element } = dto; + + await this.browserTool.execCommand( + clientOptions, + BrowserToolListCommands.LPush, + [keyName, element], + ); + } + + public async createListWithExpiration( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateListWithExpireDto, + ): Promise { + const { keyName, element, expire } = dto; + const [ + transactionError, + transactionResults, + ] = await this.browserTool.execMulti(clientOptions, [ + [BrowserToolListCommands.LPush, keyName, element], + [BrowserToolKeysCommands.Expire, keyName, expire], + ]); + catchTransactionError(transactionError, transactionResults); + } +} diff --git a/redisinsight/api/src/modules/browser/services/rejson-rl-business/rejson-rl-business.service.spec.ts b/redisinsight/api/src/modules/browser/services/rejson-rl-business/rejson-rl-business.service.spec.ts new file mode 100644 index 0000000000..002d9b4255 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/rejson-rl-business/rejson-rl-business.service.spec.ts @@ -0,0 +1,1156 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { randomBytes } from 'crypto'; +import { when } from 'jest-when'; +import { + mockBrowserAnalyticsService, + mockRedisConsumer, + mockRedisNoPermError, + mockRedisWrongTypeError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { ReplyError } from 'src/models'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolKeysCommands, + BrowserToolRejsonRlCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { RejsonRlBusinessService } from './rejson-rl-business.service'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const testKey = 'somejson'; +const testSerializedObject = JSON.stringify({ some: 'object' }); +const testPath = '.'; +const testExpire = 30; + +describe('JsonBusinessService', () => { + let service: RejsonRlBusinessService; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RejsonRlBusinessService, + { + provide: BrowserAnalyticsService, + useFactory: mockBrowserAnalyticsService, + }, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(RejsonRlBusinessService); + browserTool = module.get(BrowserToolService); + }); + + describe('getJson', () => { + const mockRedisCallsForSafeResponse = ( + path, + key, + type, + value, + cardinality = 0, + ) => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + testKey, + path, + ]) + .mockReturnValue(type); + + if (value !== undefined) { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + path, + ]) + .mockReturnValue(JSON.stringify(value)); + } + + switch (type) { + case 'array': + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonArrLen, + [testKey, path], + ) + .mockReturnValue(cardinality); + break; + case 'object': + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonObjLen, + [testKey, path], + ) + .mockReturnValue(cardinality); + break; + default: + } + }; + + describe('full json download', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonDebug, + ['MEMORY', testKey, testPath], + ) + .mockReturnValue(10); + }); + + it('should throw BadRequest error when no key found in the database', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonDebug, + ['MEMORY', testKey, testPath], + ) + .mockResolvedValue(null); + + try { + await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual( + `There is no such path: "${testPath}" in key: "${testKey}"`, + ); + } + }); + it('should throw BadRequest error when incorrect type of a key', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockResolvedValue(null); + + try { + await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + forceRetrieve: true, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + } + }); + it('should throw BadRequest when try to force get not existing path/key', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'JSON.DEBUG', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + } + }); + it('should throw Forbidden error when no perms for an action for getJson', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'JSON.DEBUG', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + it('should throw BadRequest error when module not loaded for getJson', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + message: `unknown command ${BrowserToolRejsonRlCommands.JsonGet}`, + command: BrowserToolRejsonRlCommands.JsonGet, + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + }); + it('should throw InternalError when some unexpected error happened', async () => { + browserTool.execCommand.mockRejectedValue(new Error()); // no message here + + try { + await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + it('should return data (string)', async () => { + const testData = 'some string'; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockReturnValue(JSON.stringify(testData)); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: true, + path: testPath, + data: testData, + }); + }); + it('should return data (number)', async () => { + const testData = 3.14; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockReturnValue(JSON.stringify(testData)); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: true, + path: testPath, + data: testData, + }); + }); + it('should return data (integer)', async () => { + const testData = 123; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockReturnValue(JSON.stringify(testData)); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: true, + path: testPath, + data: testData, + }); + }); + it('should return data (boolean)', async () => { + const testData = true; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockReturnValue(JSON.stringify(testData)); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: true, + path: testPath, + data: testData, + }); + }); + it('should return data (null)', async () => { + const testData = null; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockReturnValue(JSON.stringify(testData)); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: true, + path: testPath, + data: testData, + }); + }); + it('should return data (array)', async () => { + const testData = [ + 1, + 'str', + false, + null, + 0.98, + [1, 2], + { some: 'field' }, + ]; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockReturnValue(JSON.stringify(testData)); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: true, + path: testPath, + data: testData, + }); + }); + it('should return data (object)', async () => { + const testData = { + someStr: 'field', + someArr: [], + someBool: true, + someNumber: 12.22, + someInt: 1222, + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockReturnValue(JSON.stringify(testData)); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: true, + path: testPath, + data: testData, + }); + }); + it('should return full json data when forceRetrieve is true', async () => { + const testData = { + someStr: 'field', + someArr: [], + someBool: true, + someNumber: 12.22, + someInt: 1222, + }; + + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonDebug, + ['MEMORY', testKey, testPath], + ) + .mockReturnValue(1025); + + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockReturnValue(JSON.stringify(testData)); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + forceRetrieve: true, + }); + + expect(result).toEqual({ + downloaded: true, + path: testPath, + data: testData, + }); + }); + }); + describe('partial json download', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonDebug, + ['MEMORY', testKey, testPath], + ) + .mockReturnValue(1025); + }); + + it('should return full string value even if size is above the limit', async () => { + const testData = randomBytes(2000).toString('hex'); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonGet, [ + testKey, + testPath, + ]) + .mockReturnValue(JSON.stringify(testData)); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + testKey, + testPath, + ]) + .mockReturnValue('string'); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: false, + path: testPath, + data: testData, + type: 'string', + }); + }); + it('should return array with scalar values and safe struct types descriptions', async () => { + const testData = [ + 12, + 3.14, + 'str', + false, + null, + [1, 2, 3], + { key1: 'value1', key2: 'value2' }, + ]; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + testKey, + testPath, + ]) + .mockReturnValue('array'); + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonArrLen, + [testKey, testPath], + ) + .mockReturnValue(7); + + mockRedisCallsForSafeResponse('[0]', 0, 'integer', testData[0]); + mockRedisCallsForSafeResponse('[1]', 1, 'number', testData[1]); + mockRedisCallsForSafeResponse('[2]', 2, 'string', testData[2]); + mockRedisCallsForSafeResponse('[3]', 3, 'boolean', testData[3]); + mockRedisCallsForSafeResponse('[4]', 4, 'null', testData[4]); + mockRedisCallsForSafeResponse('[5]', 5, 'array', undefined, 3); + mockRedisCallsForSafeResponse('[6]', 6, 'object', undefined, 2); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: false, + path: testPath, + type: 'array', + data: [ + { + key: 0, + path: '[0]', + cardinality: 1, + type: 'integer', + value: testData[0], + }, + { + key: 1, + path: '[1]', + cardinality: 1, + type: 'number', + value: testData[1], + }, + { + key: 2, + path: '[2]', + cardinality: 1, + type: 'string', + value: testData[2], + }, + { + key: 3, + path: '[3]', + cardinality: 1, + type: 'boolean', + value: testData[3], + }, + { + key: 4, + path: '[4]', + cardinality: 1, + type: 'null', + value: testData[4], + }, + { + key: 5, + path: '[5]', + cardinality: 3, + type: 'array', + }, + { + key: 6, + path: '[6]', + cardinality: 2, + type: 'object', + }, + ], + }); + }); + it('should return array with scalar values in a custom path', async () => { + const path = '["customPath"]'; + const testData = [12, 'str']; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonDebug, + ['MEMORY', testKey, path], + ) + .mockReturnValue(1025); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + testKey, + path, + ]) + .mockReturnValue('array'); + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonArrLen, + [testKey, path], + ) + .mockReturnValue(2); + + mockRedisCallsForSafeResponse( + `${path}[0]`, + 0, + 'integer', + testData[0], + ); + mockRedisCallsForSafeResponse( + `${path}[1]`, + 1, + 'string', + testData[1], + ); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path, + }); + + expect(result).toEqual({ + downloaded: false, + path, + type: 'array', + data: [ + { + key: 0, + path: `${path}[0]`, + cardinality: 1, + type: 'integer', + value: testData[0], + }, + { + key: 1, + path: `${path}[1]`, + cardinality: 1, + type: 'string', + value: testData[1], + }, + ], + }); + }); + it('should return object with scalar values and safe struct types descriptions', async () => { + const testData = { + fInt: 12, + fNum: 3.14, + fStr: 'str', + fBool: false, + fNull: null, + fArr: [1, 2, 3], + fObj: { key1: 'value1', key2: 'value2' }, + }; + + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + testKey, + testPath, + ]) + .mockReturnValue('object'); + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonObjKeys, + [testKey, testPath], + ) + .mockReturnValue(Object.keys(testData)); + + mockRedisCallsForSafeResponse( + '["fInt"]', + 'fInt', + 'integer', + testData.fInt, + ); + mockRedisCallsForSafeResponse( + '["fNum"]', + 'fNum', + 'number', + testData.fNum, + ); + mockRedisCallsForSafeResponse( + '["fStr"]', + 'fStr', + 'string', + testData.fStr, + ); + mockRedisCallsForSafeResponse( + '["fBool"]', + 'fBool', + 'boolean', + testData.fBool, + ); + mockRedisCallsForSafeResponse( + '["fNull"]', + 'fNull', + 'null', + testData.fNull, + ); + mockRedisCallsForSafeResponse( + '["fArr"]', + 'fArr', + 'array', + undefined, + 3, + ); + mockRedisCallsForSafeResponse( + '["fObj"]', + 'fObj', + 'object', + undefined, + 2, + ); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(result).toEqual({ + downloaded: false, + path: testPath, + type: 'object', + data: [ + { + key: 'fInt', + path: '["fInt"]', + cardinality: 1, + type: 'integer', + value: testData.fInt, + }, + { + key: 'fNum', + path: '["fNum"]', + cardinality: 1, + type: 'number', + value: testData.fNum, + }, + { + key: 'fStr', + path: '["fStr"]', + cardinality: 1, + type: 'string', + value: testData.fStr, + }, + { + key: 'fBool', + path: '["fBool"]', + cardinality: 1, + type: 'boolean', + value: testData.fBool, + }, + { + key: 'fNull', + path: '["fNull"]', + cardinality: 1, + type: 'null', + value: testData.fNull, + }, + { + key: 'fArr', + path: '["fArr"]', + cardinality: 3, + type: 'array', + }, + { + key: 'fObj', + path: '["fObj"]', + cardinality: 2, + type: 'object', + }, + ], + }); + }); + it('should return object with scalar values in a custom path', async () => { + const path = '["customPath"]'; + const testData = { + fInt: 12, + fStr: 'str', + }; + + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonDebug, + ['MEMORY', testKey, path], + ) + .mockReturnValue(1025); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonType, [ + testKey, + path, + ]) + .mockReturnValue('object'); + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonObjKeys, + [testKey, path], + ) + .mockReturnValue(Object.keys(testData)); + + mockRedisCallsForSafeResponse( + `${path}["fInt"]`, + 'fInt', + 'integer', + testData.fInt, + ); + mockRedisCallsForSafeResponse( + `${path}["fStr"]`, + 'fStr', + 'string', + testData.fStr, + ); + + const result = await service.getJson(mockClientOptions, { + keyName: testKey, + path, + }); + + expect(result).toEqual({ + downloaded: false, + path, + type: 'object', + data: [ + { + key: 'fInt', + path: `${path}["fInt"]`, + cardinality: 1, + type: 'integer', + value: testData.fInt, + }, + { + key: 'fStr', + path: `${path}["fStr"]`, + cardinality: 1, + type: 'string', + value: testData.fStr, + }, + ], + }); + }); + }); + }); + describe('create', () => { + beforeEach(() => { + browserTool.execCommand.mockReturnValue('OK'); + }); + it('should throw Conflict error when key is already in the database', async () => { + browserTool.execCommand.mockReturnValue(null); + + try { + await service.create(mockClientOptions, { + keyName: testKey, + data: testSerializedObject, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(ConflictException); + expect(err.message).toEqual(ERROR_MESSAGES.KEY_NAME_EXIST); + } + }); + it('should throw Forbidden error when no perms for an action for create', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.create(mockClientOptions, { + keyName: testKey, + data: testSerializedObject, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + it('should throw BadRequest error when module not loaded for create', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + message: `unknown command ${BrowserToolRejsonRlCommands.JsonSet}`, + command: BrowserToolRejsonRlCommands.JsonSet, + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.create(mockClientOptions, { + keyName: testKey, + data: testSerializedObject, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + }); + it('should silently handle key expire error and log it', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolRejsonRlCommands.JsonSet, [ + testKey, + testPath, + testSerializedObject, + 'NX', + ]) + .mockReturnValue('OK'); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Expire, [ + testKey, + testExpire, + ]) + .mockRejectedValue(replyError); + + await service.create(mockClientOptions, { + keyName: testKey, + data: testSerializedObject, + expire: testExpire, + }); + expect(browserTool.execCommand).lastCalledWith( + mockClientOptions, + BrowserToolKeysCommands.Expire, + [testKey, testExpire], + ); + }); + + it('should successful create key', async () => { + await service.create(mockClientOptions, { + keyName: testKey, + data: testSerializedObject, + }); + }); + }); + describe('jsonSet', () => { + beforeEach(() => { + browserTool.execCommand.mockReturnValue('OK'); + }); + it('should throw NotFound error when key does not exists for jsonSet', async () => { + browserTool.execCommand.mockReturnValue(0); + + try { + await service.jsonSet(mockClientOptions, { + keyName: testKey, + path: testPath, + data: testSerializedObject, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(NotFoundException); + expect(err.message).toEqual(ERROR_MESSAGES.KEY_NOT_EXIST); + } + }); + it('should throw BadRequest error when module not loaded for jsonSet', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + message: `unknown command ${BrowserToolRejsonRlCommands.JsonSet}`, + command: BrowserToolRejsonRlCommands.JsonSet, + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.jsonSet(mockClientOptions, { + keyName: testKey, + path: testPath, + data: testSerializedObject, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + }); + it('should throw NotFound error when try to set to the incorrect path', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + command: 'json.set', + message: "ERR index '[7]' out of range at level 1 in path", + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.jsonSet(mockClientOptions, { + keyName: testKey, + path: testPath, + data: testSerializedObject, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(NotFoundException); + expect(err.message).toEqual(ERROR_MESSAGES.PATH_NOT_EXISTS()); + } + }); + it('should throw Forbidden error when no perms for an action for jsonSet', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.jsonSet(mockClientOptions, { + keyName: testKey, + path: testPath, + data: testSerializedObject, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + it('should successful modify data', async () => { + await service.jsonSet(mockClientOptions, { + keyName: testKey, + path: testPath, + data: testSerializedObject, + }); + + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.Exists, + [testKey], + ); + expect(browserTool.execCommand).lastCalledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonSet, + [testKey, testPath, testSerializedObject], + ); + }); + }); + describe('arrAppend', () => { + beforeEach(() => { + browserTool.execCommand.mockReturnValue('OK'); + }); + it('should throw NotFound error when key does not exists', async () => { + browserTool.execCommand.mockReturnValue(0); + + try { + await service.arrAppend(mockClientOptions, { + keyName: testKey, + path: testPath, + data: [testSerializedObject], + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(NotFoundException); + expect(err.message).toEqual(ERROR_MESSAGES.KEY_NOT_EXIST); + } + }); + it('should throw BadRequest error when module not loaded', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + message: `unknown command ${BrowserToolRejsonRlCommands.JsonArrAppend}`, + command: BrowserToolRejsonRlCommands.JsonArrAppend, + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.arrAppend(mockClientOptions, { + keyName: testKey, + path: testPath, + data: [testSerializedObject], + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + }); + it('should throw Forbidden error when no perms for an action', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.arrAppend(mockClientOptions, { + keyName: testKey, + path: testPath, + data: [testSerializedObject], + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + it('should successful modify data', async () => { + await service.arrAppend(mockClientOptions, { + keyName: testKey, + path: testPath, + data: [testSerializedObject, testSerializedObject], + }); + + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.Exists, + [testKey], + ); + expect(browserTool.execCommand).lastCalledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonArrAppend, + [testKey, testPath, testSerializedObject, testSerializedObject], + ); + }); + }); + describe('remove', () => { + beforeEach(() => { + browserTool.execCommand.mockReturnValue('OK'); + }); + it('should throw NotFound error when key does not exists', async () => { + browserTool.execCommand.mockReturnValue(0); + + try { + await service.remove(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(NotFoundException); + expect(err.message).toEqual(ERROR_MESSAGES.KEY_NOT_EXIST); + } + }); + it('should throw BadRequest error when module not loaded', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + message: `unknown command ${BrowserToolRejsonRlCommands.JsonDel}`, + command: BrowserToolRejsonRlCommands.JsonDel, + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.remove(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + }); + it('should throw Forbidden error when no perms for an action', async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + }; + browserTool.execCommand.mockRejectedValue(replyError); + + try { + await service.remove(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(ForbiddenException); + } + }); + it('should successful remove path', async () => { + await service.remove(mockClientOptions, { + keyName: testKey, + path: testPath, + }); + + expect(browserTool.execCommand).toHaveBeenNthCalledWith( + 1, + mockClientOptions, + BrowserToolKeysCommands.Exists, + [testKey], + ); + expect(browserTool.execCommand).lastCalledWith( + mockClientOptions, + BrowserToolRejsonRlCommands.JsonDel, + [testKey, testPath], + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/rejson-rl-business/rejson-rl-business.service.ts b/redisinsight/api/src/modules/browser/services/rejson-rl-business/rejson-rl-business.service.ts new file mode 100644 index 0000000000..d1c978bc05 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/rejson-rl-business/rejson-rl-business.service.ts @@ -0,0 +1,501 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { RedisErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { catchAclError } from 'src/utils'; +import config from 'src/utils/config'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { + CreateRejsonRlWithExpireDto, + GetRejsonRlDto, + GetRejsonRlResponseDto, + ModifyRejsonRlArrAppendDto, + ModifyRejsonRlSetDto, + RedisDataType, + RemoveRejsonRlDto, + RemoveRejsonRlResponse, + SafeRejsonRlDataDtO, +} from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolRejsonRlCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { BrowserToolService } from '../browser-tool/browser-tool.service'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +@Injectable() +export class RejsonRlBusinessService { + private logger = new Logger('JsonBusinessService'); + + constructor( + private browserTool: BrowserToolService, + private browserAnalyticsService: BrowserAnalyticsService, + ) {} + + private async forceGetJson( + clientOptions: IFindRedisClientInstanceByOptions, + keyName: string, + path: string, + ): Promise { + const data = await this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonGet, + [keyName, path], + ); + + if (data === null) { + throw new BadRequestException( + `There is no such path: "${path}" in key: "${keyName}"`, + ); + } + + return JSON.parse(data); + } + + private async estimateSize( + clientOptions: IFindRedisClientInstanceByOptions, + keyName: string, + path: string, + ): Promise { + const size = await this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonDebug, + ['MEMORY', keyName, path], + ); + + if (size === null) { + throw new BadRequestException( + `There is no such path: "${path}" in key: "${keyName}"`, + ); + } + + return size; + } + + private async getObjectKeys( + clientOptions: IFindRedisClientInstanceByOptions, + keyName: string, + path: string, + ): Promise { + return this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonObjKeys, + [keyName, path], + ); + } + + private async getJsonDataType( + clientOptions: IFindRedisClientInstanceByOptions, + keyName: string, + path: string, + ): Promise { + return this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonType, + [keyName, path], + ); + } + + private async getDetails( + clientOptions: IFindRedisClientInstanceByOptions, + keyName: string, + path: string, + key: string | number, + ): Promise { + const details = { + key, + path, + cardinality: 1, + }; + + const objectKeyType = await this.getJsonDataType( + clientOptions, + keyName, + path, + ); + + details['type'] = objectKeyType; + switch (objectKeyType) { + case 'object': + details[ + 'cardinality' + ] = await this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonObjLen, + [keyName, path], + ); + break; + case 'array': + details[ + 'cardinality' + ] = await this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonArrLen, + [keyName, path], + ); + break; + default: + details['value'] = await this.forceGetJson( + clientOptions, + keyName, + path, + ); + break; + } + + return details; + } + + private async safeGetJsonByType( + clientOptions: IFindRedisClientInstanceByOptions, + keyName: string, + path: string, + type: string, + ): Promise { + const result = []; + let objectKeys: string[]; + let arrayLength: number; + + switch (type) { + case 'object': + objectKeys = await this.getObjectKeys( + clientOptions, + keyName, + path, + ); + for (const objectKey of objectKeys) { + const rootPath = path === '.' ? '' : path; + const childPath = objectKey.includes('"') + ? `['${objectKey}']` + : `["${objectKey}"]`; + const fullObjectKeyPath = `${rootPath}${childPath}`; + result.push( + await this.getDetails( + clientOptions, + keyName, + fullObjectKeyPath, + objectKey, + ), + ); + } + + break; + case 'array': + arrayLength = await this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonArrLen, + [keyName, path], + ); + + for (let i = 0; i < arrayLength; i += 1) { + const fullObjectKeyPath = `${path === '.' ? '' : path}[${i}]`; + result.push( + await this.getDetails(clientOptions, keyName, fullObjectKeyPath, i), + ); + } + break; + default: + return this.forceGetJson(clientOptions, keyName, path); + } + + return result; + } + + /** + * Method to create REJSON-RL type + * Supports key TTL + * + * @param clientOptions + * @param dto + */ + public async create( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateRejsonRlWithExpireDto, + ): Promise { + this.logger.log('Creating REJSON-RL data type.'); + + const { keyName, data, expire } = dto; + try { + const result = await this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonSet, + [keyName, '.', data, 'NX'], + ); + + if (!result) { + throw new ConflictException(ERROR_MESSAGES.KEY_NAME_EXIST); + } + + this.logger.log('Succeed to create REJSON-RL key type.'); + this.browserAnalyticsService.sendKeyAddedEvent( + clientOptions.instanceId, + RedisDataType.JSON, + { + TTL: -1, + }, + ); + + if (expire) { + try { + await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Expire, + [keyName, expire], + ); + this.browserAnalyticsService.sendKeyTTLChangedEvent( + clientOptions.instanceId, + expire, + -1, + ); + } catch (err) { + this.logger.error( + `Unable to set expire ${expire} for REJSON-RL key ${keyName}.`, + ); + } + } + } catch (error) { + this.logger.error('Failed to create REJSON-RL key type.', error); + + if (error instanceof ConflictException) { + throw error; + } + + if (error.message.includes(RedisErrorCodes.UnknownCommand)) { + throw new BadRequestException( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + + catchAclError(error); + } + } + + public async getJson( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetRejsonRlDto, + ): Promise { + this.logger.log('Getting json by key.'); // todo: investigate logger implementation + const { keyName, path, forceRetrieve } = dto; + + const result: GetRejsonRlResponseDto = { + downloaded: true, + path, + data: null, + }; + + try { + // Get value in the path without any checks + if (forceRetrieve) { + result.data = await this.forceGetJson(clientOptions, keyName, path); + return result; + } + + const jsonSize = await this.estimateSize(clientOptions, keyName, path); + if (jsonSize > config.get('modules')['json']['sizeThreshold']) { + const type = await this.getJsonDataType(clientOptions, keyName, path); + result.downloaded = false; + result.type = type; + result.data = await this.safeGetJsonByType( + clientOptions, + keyName, + path, + type, + ); + } else { + result.data = await this.forceGetJson(clientOptions, keyName, path); + } + + return result; + } catch (error) { + this.logger.error('Failed to get json.', error); + + if (error.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + if (error.message.includes(RedisErrorCodes.UnknownCommand)) { + throw new BadRequestException( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + + // todo: refactor error handling across the project + if (error instanceof BadRequestException) { + throw error; + } + + throw catchAclError(error); + } + } + + /** + * Method to modify REJSON-RL type using JSON.SET command + * @param clientOptions + * @param dto + */ + public async jsonSet( + clientOptions: IFindRedisClientInstanceByOptions, + dto: ModifyRejsonRlSetDto, + ): Promise { + this.logger.log('Modifying REJSON-RL data type.'); + const { keyName, path, data } = dto; + + try { + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + + if (!exists) { + throw new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST); + } + const type = await this.getJsonDataType(clientOptions, keyName, path); + await this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonSet, + [keyName, path, data], + ); + if (type) { + this.browserAnalyticsService.sendJsonPropertyEditedEvent( + clientOptions.instanceId, + path, + ); + } else { + this.browserAnalyticsService.sendJsonPropertyAddedEvent( + clientOptions.instanceId, + path, + ); + } + + this.logger.log('Succeed to modify REJSON-RL key type.'); + } catch (error) { + this.logger.error('Failed to modify REJSON-RL key type.', error); + + if (error instanceof NotFoundException) { + throw error; + } + + if ( + error.message.includes('index') + && error.message.includes('out of range') + ) { + throw new NotFoundException(ERROR_MESSAGES.PATH_NOT_EXISTS()); + } + + if (error.message.includes(RedisErrorCodes.UnknownCommand)) { + throw new BadRequestException( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + + throw catchAclError(error); + } + } + + /** + * Method to modify REJSON-RL type using JSON.ARRAPPEND command + * @param clientOptions + * @param dto + */ + public async arrAppend( + clientOptions: IFindRedisClientInstanceByOptions, + dto: ModifyRejsonRlArrAppendDto, + ): Promise { + this.logger.log('Modifying REJSON-RL data type.'); + const { keyName, path, data } = dto; + try { + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + + if (!exists) { + throw new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST); + } + + await this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonArrAppend, + [keyName, path, ...data], + ); + this.browserAnalyticsService.sendJsonArrayPropertyAppendEvent( + clientOptions.instanceId, + path, + ); + this.logger.log('Succeed to modify REJSON-RL key type.'); + } catch (error) { + this.logger.error('Failed to modify REJSON-RL key type', error); + + if (error instanceof NotFoundException) { + throw error; + } + + if (error.message.includes(RedisErrorCodes.UnknownCommand)) { + throw new BadRequestException( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + + throw catchAclError(error); + } + } + + /** + * Method to remove REJSON-RL path using JSON.DEL command + * @param clientOptions + * @param dto + */ + public async remove( + clientOptions: IFindRedisClientInstanceByOptions, + dto: RemoveRejsonRlDto, + ): Promise { + this.logger.log('Removing REJSON-RL data.'); + const { keyName, path } = dto; + try { + const exists = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + + if (!exists) { + throw new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST); + } + + const affected = await this.browserTool.execCommand( + clientOptions, + BrowserToolRejsonRlCommands.JsonDel, + [keyName, path], + ); + if (affected) { + this.browserAnalyticsService.sendJsonPropertyDeletedEvent( + clientOptions.instanceId, + path, + ); + } + this.logger.log('Succeed to remove REJSON-RL path.'); + return { affected }; + } catch (error) { + this.logger.error('Failed to remove REJSON-RL path.', error); + + if (error instanceof NotFoundException) { + throw error; + } + + if (error.message.includes(RedisErrorCodes.UnknownCommand)) { + throw new BadRequestException( + ERROR_MESSAGES.REDIS_MODULE_IS_REQUIRED('RedisJSON'), + ); + } + + throw catchAclError(error); + } + } +} diff --git a/redisinsight/api/src/modules/browser/services/set-business/set-business.service.spec.ts b/redisinsight/api/src/modules/browser/services/set-business/set-business.service.spec.ts new file mode 100644 index 0000000000..ef29730363 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/set-business/set-business.service.spec.ts @@ -0,0 +1,492 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { when } from 'jest-when'; +import { + mockBrowserAnalyticsService, + mockRedisConsumer, + mockRedisNoPermError, + mockRedisWrongTypeError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import config from 'src/utils/config'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { ReplyError } from 'src/models'; +import { + BrowserToolKeysCommands, + BrowserToolSetCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { SetBusinessService } from './set-business.service'; +import { + AddMembersToSetDto, + CreateSetWithExpireDto, + DeleteMembersFromSetDto, + GetSetMembersDto, + GetSetMembersResponse, +} from '../../dto'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; +import { BrowserToolService } from '../browser-tool/browser-tool.service'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockAddMemberDto: AddMembersToSetDto = { + keyName: 'testSet', + members: ['Lorem ipsum dolor sit amet.'], +}; + +const mockDeleteMembersDto: DeleteMembersFromSetDto = { + keyName: mockAddMemberDto.keyName, + members: mockAddMemberDto.members, +}; + +const mockGetMembersDto: GetSetMembersDto = { + keyName: mockAddMemberDto.keyName, + cursor: 0, + count: REDIS_SCAN_CONFIG.countDefault || 15, + match: '*', +}; + +const mockSetMembers: string[] = ['member']; + +const mockGetSetMembersResponse: GetSetMembersResponse = { + keyName: mockGetMembersDto.keyName, + nextCursor: 0, + total: mockSetMembers.length, + members: mockSetMembers, +}; + +describe('SetBusinessService', () => { + let service: SetBusinessService; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SetBusinessService, + { + provide: BrowserAnalyticsService, + useFactory: mockBrowserAnalyticsService, + }, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(SetBusinessService); + browserTool = module.get(BrowserToolService); + }); + + describe('createSet', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddMemberDto.keyName, + ]) + .mockResolvedValue(false); + service.createSetWithExpiration = jest.fn(); + }); + it('create set with expiration', async () => { + service.createSetWithExpiration = jest.fn().mockResolvedValue(undefined); + + await expect( + service.createSet(mockClientOptions, { + ...mockAddMemberDto, + expire: 1000, + }), + ).resolves.not.toThrow(); + expect(service.createSetWithExpiration).toHaveBeenCalled(); + }); + it('create set without expiration', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolSetCommands.SAdd, [ + mockAddMemberDto.keyName, + ...mockAddMemberDto.members, + ]) + .mockResolvedValue(1); + + await expect( + service.createSet(mockClientOptions, mockAddMemberDto), + ).resolves.not.toThrow(); + expect(service.createSetWithExpiration).not.toHaveBeenCalled(); + }); + it('key with this name exist', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddMemberDto.keyName, + ]) + .mockResolvedValue(true); + + await expect( + service.createSet(mockClientOptions, mockAddMemberDto), + ).rejects.toThrow(ConflictException); + expect(browserTool.execCommand).toHaveBeenCalledTimes(1); + expect(browserTool.execMulti).not.toHaveBeenCalled(); + }); + it("try to use 'SADD' command not for set data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'SADD', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolSetCommands.SAdd, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.createSet(mockClientOptions, mockAddMemberDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for createSet", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'SADD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.createSet(mockClientOptions, mockAddMemberDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('createSetWithExpiration', () => { + const dto: CreateSetWithExpireDto = { + ...mockAddMemberDto, + expire: 1000, + }; + it('succeed to create Set data type with expiration', async () => { + when(browserTool.execMulti) + .calledWith(mockClientOptions, [ + [BrowserToolSetCommands.SAdd, dto.keyName, ...dto.members], + [BrowserToolKeysCommands.Expire, dto.keyName, dto.expire], + ]) + .mockResolvedValue([ + null, + [ + [null, mockAddMemberDto.members.length], + [null, 1], + ], + ]); + + const result = await service.createSetWithExpiration( + mockClientOptions, + dto, + ); + expect(result).toBe(mockAddMemberDto.members.length); + }); + it('throw transaction error', async () => { + const transactionError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'SADD', + }; + browserTool.execMulti.mockResolvedValue([transactionError, null]); + + await expect( + service.createSetWithExpiration(mockClientOptions, dto), + ).rejects.toEqual(transactionError); + }); + }); + + describe('getMembers', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolSetCommands.SCard, [ + mockGetMembersDto.keyName, + ]) + .mockResolvedValue(mockSetMembers.length); + }); + it('succeed to get members of the set', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolSetCommands.SScan, + expect.anything(), + ) + .mockResolvedValue([0, mockSetMembers]); + + const result = await service.getMembers( + mockClientOptions, + mockGetMembersDto, + ); + + expect(result).toEqual(mockGetSetMembersResponse); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolSetCommands.SScan, + expect.anything(), + ); + }); + it('succeed to find exact member in the set', async () => { + const dto: GetSetMembersDto = { + ...mockGetMembersDto, + match: mockSetMembers[0], + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolSetCommands.SIsMember, [ + dto.keyName, + dto.match, + ]) + .mockResolvedValue(1); + + const result = await service.getMembers(mockClientOptions, dto); + + expect(result).toEqual(mockGetSetMembersResponse); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolSetCommands.SScan, + expect.anything(), + ); + }); + it('failed to find exact member in the set', async () => { + const dto: GetSetMembersDto = { + ...mockGetMembersDto, + match: mockSetMembers[0], + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolSetCommands.SIsMember, [ + dto.keyName, + dto.match, + ]) + .mockResolvedValue(0); + + const result = await service.getMembers(mockClientOptions, dto); + + expect(result).toEqual({ ...mockGetSetMembersResponse, members: [] }); + }); + it('should not call scan when math contains escaped glob', async () => { + const dto: GetSetMembersDto = { + ...mockGetMembersDto, + match: 'm\\[a-e\\]mber', + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolSetCommands.SIsMember, [ + dto.keyName, + 'm[a-e]mber', + ]) + .mockResolvedValue(1); + + const result = await service.getMembers(mockClientOptions, dto); + + expect(result).toEqual({ + ...mockGetSetMembersResponse, + members: ['m[a-e]mber'], + }); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolSetCommands.SScan, + expect.anything(), + ); + }); + // TODO: uncomment after enabling threshold for set scan + // it('should stop set full scan', async () => { + // const dto: GetSetMembersDto = { + // ...mockGetMembersDto, + // count: REDIS_SCAN_CONFIG.countDefault, + // match: '*un-exist-member*', + // }; + // const maxScanCalls = Math.round( + // REDIS_SCAN_CONFIG.countThreshold / REDIS_SCAN_CONFIG.countDefault, + // ); + // when(browserTool.execCommand) + // .calledWith( + // mockClientOptions, + // BrowserToolSetCommands.SScan, + // expect.anything(), + // ) + // .mockResolvedValue(['200', []]); + // + // await service.getMembers(mockClientOptions, dto); + // + // expect(browserTool.execCommand).toHaveBeenCalledTimes(maxScanCalls + 1); + // }); + it('key with this name does not exist for getMembers', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolSetCommands.SCard, [ + mockGetMembersDto.keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.getMembers(mockClientOptions, mockGetMembersDto), + ).rejects.toThrow(NotFoundException); + }); + it("try to use 'SCARD' command not for list data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'SCARD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.getMembers(mockClientOptions, mockGetMembersDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for getMembers", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'SCARD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.getMembers(mockClientOptions, mockGetMembersDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('addMembers', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddMemberDto.keyName, + ]) + .mockResolvedValue(true); + }); + it('succeed to add members to the Set data type', async () => { + const { keyName, members } = mockAddMemberDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolSetCommands.SAdd, [ + keyName, + ...members, + ]) + .mockResolvedValue(1); + + await expect( + service.addMembers(mockClientOptions, mockAddMemberDto), + ).resolves.not.toThrow(); + }); + it('key with this name does not exist for addMembers', async () => { + const { keyName, members } = mockAddMemberDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddMemberDto.keyName, + ]) + .mockResolvedValue(false); + + await expect( + service.addMembers(mockClientOptions, mockAddMemberDto), + ).rejects.toThrow(NotFoundException); + expect( + browserTool.execCommand, + ).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolSetCommands.SAdd, + [keyName, ...members], + ); + }); + it("try to use 'SADD' command not for set data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'SADD', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolSetCommands.SAdd, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.addMembers(mockClientOptions, mockAddMemberDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for addMembers", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'SADD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.addMembers(mockClientOptions, mockAddMemberDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('deleteMembers', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockDeleteMembersDto.keyName, + ]) + .mockResolvedValue(true); + }); + it('succeeded to delete members from Set data type', async () => { + const { members, keyName } = mockDeleteMembersDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolSetCommands.SRem, [ + keyName, + ...members, + ]) + .mockResolvedValue(members.length); + + const result = await service.deleteMembers( + mockClientOptions, + mockDeleteMembersDto, + ); + + expect(result).toEqual({ affected: members.length }); + }); + it('key with this name does not exist for deleteMembers', async () => { + const { members, keyName } = mockDeleteMembersDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + keyName, + ]) + .mockResolvedValue(false); + + await expect( + service.deleteMembers(mockClientOptions, mockDeleteMembersDto), + ).rejects.toThrow(NotFoundException); + expect( + browserTool.execCommand, + ).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolSetCommands.SRem, + [keyName, ...members], + ); + }); + it("try to use 'SREM' command not for set data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'SREM', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolSetCommands.SRem, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.deleteMembers(mockClientOptions, mockDeleteMembersDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for deleteMembers", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'SREM', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.deleteMembers(mockClientOptions, mockDeleteMembersDto), + ).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/set-business/set-business.service.ts b/redisinsight/api/src/modules/browser/services/set-business/set-business.service.ts new file mode 100644 index 0000000000..f8320b8e14 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/set-business/set-business.service.ts @@ -0,0 +1,308 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as isGlob from 'is-glob'; +import { RedisErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import config from 'src/utils/config'; +import { catchAclError, catchTransactionError, unescapeGlob } from 'src/utils'; +import { ReplyError } from 'src/models'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { + BrowserToolKeysCommands, + BrowserToolSetCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { + AddMembersToSetDto, + CreateSetWithExpireDto, + DeleteMembersFromSetDto, + DeleteMembersFromSetResponse, + GetSetMembersDto, + GetSetMembersResponse, + RedisDataType, + SetScanResponse, +} from '../../dto'; +import { BrowserToolService } from '../browser-tool/browser-tool.service'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +@Injectable() +export class SetBusinessService { + private logger = new Logger('SetBusinessService'); + + constructor( + private browserTool: BrowserToolService, + private browserAnalyticsService: BrowserAnalyticsService, + ) {} + + public async createSet( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateSetWithExpireDto, + ): Promise { + this.logger.log('Creating Set data type.'); + const { keyName } = dto; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (isExist) { + this.logger.error( + `Failed to create Set data type. ${ERROR_MESSAGES.KEY_NAME_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new ConflictException(ERROR_MESSAGES.KEY_NAME_EXIST), + ); + } + if (dto.expire) { + await this.createSetWithExpiration(clientOptions, dto); + } else { + await this.createSimpleSet(clientOptions, dto); + } + this.browserAnalyticsService.sendKeyAddedEvent( + clientOptions.instanceId, + RedisDataType.Set, + { + length: dto.members.length, + TTL: dto.expire || -1, + }, + ); + this.logger.log('Succeed to create Set data type.'); + } catch (error) { + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + this.logger.error('Failed to create Set data type.', error); + catchAclError(error); + } + return null; + } + + public async getMembers( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetSetMembersDto, + ): Promise { + this.logger.log('Getting members of the Set data type stored at key.'); + const { keyName } = dto; + let result: GetSetMembersResponse = { + keyName, + total: 0, + members: [], + nextCursor: dto.cursor, + }; + + try { + result.total = await this.browserTool.execCommand( + clientOptions, + BrowserToolSetCommands.SCard, + [keyName], + ); + if (!result.total) { + this.logger.error( + `Failed to get members of the Set data type. Not Found key: ${keyName}.`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + if (dto.match && !isGlob(dto.match, { strict: false })) { + const member = unescapeGlob(dto.match); + result.nextCursor = 0; + const memberIsExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolSetCommands.SIsMember, + [keyName, member], + ); + if (memberIsExist) { + result.members.push(member); + } + } else { + const scanResult = await this.scanSet(clientOptions, dto); + result = { ...result, ...scanResult }; + } + this.browserAnalyticsService.sendKeyScannedEvent( + clientOptions.instanceId, + RedisDataType.Set, + dto.match, + { + length: result.total, + }, + ); + this.logger.log('Succeed to get members of the Set data type.'); + return result; + } catch (error) { + this.logger.error('Failed to get members of the Set data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + throw catchAclError(error); + } + } + + public async addMembers( + clientOptions: IFindRedisClientInstanceByOptions, + dto: AddMembersToSetDto, + ): Promise { + this.logger.log('Adding members to the Set data type.'); + const { keyName, members } = dto; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to add members to Set data type. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + const added = await this.browserTool.execCommand( + clientOptions, + BrowserToolSetCommands.SAdd, + [keyName, ...members], + ); + if (added) { + this.browserAnalyticsService.sendKeyValueAddedEvent( + clientOptions.instanceId, + RedisDataType.Set, + { + numberOfAdded: added, + }, + ); + } + this.logger.log('Succeed to add members to Set data type.'); + } catch (error) { + this.logger.error('Failed to add members to Set data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + return null; + } + + public async deleteMembers( + clientOptions: IFindRedisClientInstanceByOptions, + dto: DeleteMembersFromSetDto, + ): Promise { + this.logger.log('Deleting members from the Set data type.'); + const { keyName, members } = dto; + let result; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to delete members from the Set data type. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolSetCommands.SRem, + [keyName, ...members], + ); + if (result) { + this.browserAnalyticsService.sendKeyValueRemovedEvent( + clientOptions.instanceId, + RedisDataType.Set, + { + numberOfRemoved: result, + }, + ); + } + } catch (error) { + this.logger.error('Failed to delete members from the Set data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + this.logger.log('Succeed to delete members from the Set data type.'); + return { affected: result }; + } + + public async createSimpleSet( + clientOptions: IFindRedisClientInstanceByOptions, + dto: AddMembersToSetDto, + ): Promise { + const { keyName, members } = dto; + + return await this.browserTool.execCommand( + clientOptions, + BrowserToolSetCommands.SAdd, + [keyName, ...members], + ); + } + + public async createSetWithExpiration( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateSetWithExpireDto, + ): Promise { + const { keyName, members, expire } = dto; + + const [ + transactionError, + transactionResults, + ] = await this.browserTool.execMulti(clientOptions, [ + [BrowserToolSetCommands.SAdd, keyName, ...members], + [BrowserToolKeysCommands.Expire, keyName, expire], + ]); + catchTransactionError(transactionError, transactionResults); + const execResult = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [added] = execResult; + return added; + } + + public async scanSet( + clientOptions: IFindRedisClientInstanceByOptions, + dto: GetSetMembersDto, + ): Promise { + const { keyName } = dto; + const count = dto.count || REDIS_SCAN_CONFIG.countDefault; + const match = dto.match !== undefined ? dto.match : '*'; + let result: SetScanResponse = { + keyName, + nextCursor: null, + members: [], + }; + + while (result.nextCursor !== 0 && result.members.length < count) { + const scanResult = await this.browserTool.execCommand( + clientOptions, + BrowserToolSetCommands.SScan, + [ + keyName, + `${result.nextCursor || dto.cursor}`, + 'MATCH', + match, + 'COUNT', + count, + ], + ); + const [nextCursor, members] = scanResult; + result = { + ...result, + nextCursor: parseInt(nextCursor, 10), + members: [...result.members, ...members], + }; + } + return result; + } +} diff --git a/redisinsight/api/src/modules/browser/services/string-business/string-business.service.spec.ts b/redisinsight/api/src/modules/browser/services/string-business/string-business.service.spec.ts new file mode 100644 index 0000000000..32947e9d02 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/string-business/string-business.service.spec.ts @@ -0,0 +1,246 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { when } from 'jest-when'; +import { ReplyError } from 'src/models/redis-client'; +import { + mockBrowserAnalyticsService, + mockRedisConsumer, + mockRedisNoPermError, + mockRedisWrongTypeError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { + SetStringDto, + SetStringWithExpireDto, +} from 'src/modules/browser/dto/string.dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolKeysCommands, + BrowserToolStringCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { KeytarUnavailableException } from 'src/modules/core/encryption/exceptions'; +import { StringBusinessService } from './string-business.service'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +const mockSetStringDto: SetStringDto = { + keyName: 'foo', + value: 'Lorem ipsum dolor sit amet.', +}; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +describe('StringBusinessService', () => { + let service: StringBusinessService; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StringBusinessService, + { + provide: BrowserAnalyticsService, + useFactory: mockBrowserAnalyticsService, + }, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(StringBusinessService); + browserTool = module.get(BrowserToolService); + }); + + describe('setString', () => { + it('set string with expiration', async () => { + browserTool.execCommand.mockResolvedValue('OK'); + const dto: SetStringWithExpireDto = { ...mockSetStringDto, expire: 1000 }; + + await expect( + service.setString(mockClientOptions, dto), + ).resolves.not.toThrow(); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolStringCommands.Set, + [dto.keyName, dto.value, 'EX', `${dto.expire}`, 'NX'], + ); + }); + it('set string without expiration', async () => { + browserTool.execCommand.mockResolvedValue('OK'); + const dto: SetStringDto = { ...mockSetStringDto }; + + await expect( + service.setString(mockClientOptions, dto), + ).resolves.not.toThrow(); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolStringCommands.Set, + [dto.keyName, dto.value, 'NX'], + ); + }); + it('key with this name exist', async () => { + browserTool.execCommand.mockResolvedValue(null); + + await expect( + service.setString(mockClientOptions, mockSetStringDto), + ).rejects.toThrow(ConflictException); + }); + it("user don't have required permissions for setString", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'SET', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.setString(mockClientOptions, mockSetStringDto), + ).rejects.toThrow(ForbiddenException); + }); + it('Should proxy EncryptionService errors', async () => { + browserTool.execCommand.mockRejectedValueOnce(new KeytarUnavailableException()); + + await expect( + service.setString(mockClientOptions, mockSetStringDto), + ).rejects.toThrow(KeytarUnavailableException); + }); + }); + + describe('getStringValue', () => { + it('succeed to get string value', async () => { + browserTool.execCommand.mockResolvedValue(mockSetStringDto.value); + + const result = await service.getStringValue( + mockClientOptions, + mockSetStringDto.keyName, + ); + + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolStringCommands.Get, + [mockSetStringDto.keyName], + ); + expect(result).toEqual({ + value: mockSetStringDto.value, + keyName: mockSetStringDto.keyName, + }); + }); + it("try to use 'GET' command not for string data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'GET', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.getStringValue(mockClientOptions, mockSetStringDto.keyName), + ).rejects.toThrow(BadRequestException); + }); + it('key not found', async () => { + browserTool.execCommand.mockResolvedValue(null); + + await expect( + service.getStringValue(mockClientOptions, mockSetStringDto.keyName), + ).rejects.toThrow(NotFoundException); + }); + it("user don't have required permissions for getStringValue", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'GET', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.getStringValue(mockClientOptions, mockSetStringDto.keyName), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('updateStringValue', () => { + it('succeed to update string without expiration', async () => { + const dto: SetStringDto = mockSetStringDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Ttl, [ + dto.keyName, + ]) + .mockResolvedValue(-1); + + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStringCommands.Set, [ + dto.keyName, + dto.value, + 'XX', + ]) + .mockResolvedValue('OK'); + + await expect( + service.updateStringValue(mockClientOptions, dto), + ).resolves.not.toThrow(); + expect( + browserTool.execCommand, + ).toHaveBeenLastCalledWith( + mockClientOptions, + BrowserToolStringCommands.Set, + [dto.keyName, dto.value, 'XX'], + ); + }); + it('succeed to update string with expiration', async () => { + const dto: SetStringDto = mockSetStringDto; + const currentTtl = 1000; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Ttl, [ + dto.keyName, + ]) + .mockResolvedValue(currentTtl); + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolStringCommands.Set, [ + dto.keyName, + dto.value, + 'XX', + ]) + .mockResolvedValue('OK'); + + await expect( + service.updateStringValue(mockClientOptions, dto), + ).resolves.not.toThrow(); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolStringCommands.Set, + [dto.keyName, dto.value, 'XX'], + ); + expect( + browserTool.execCommand, + ).toHaveBeenLastCalledWith( + mockClientOptions, + BrowserToolKeysCommands.Expire, + [dto.keyName, currentTtl], + ); + }); + it('key with this name does not exist', async () => { + browserTool.execCommand.mockResolvedValue(null); + + await expect( + service.updateStringValue(mockClientOptions, mockSetStringDto), + ).rejects.toThrow(NotFoundException); + }); + it("user don't have required permissions for updateStringValue", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'SET', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.updateStringValue(mockClientOptions, mockSetStringDto), + ).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/string-business/string-business.service.ts b/redisinsight/api/src/modules/browser/services/string-business/string-business.service.ts new file mode 100644 index 0000000000..ecb7844336 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/string-business/string-business.service.ts @@ -0,0 +1,148 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { RedisErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { catchAclError } from 'src/utils'; +import { RedisDataType } from 'src/modules/browser/dto'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { + GetStringValueResponse, + SetStringDto, + SetStringWithExpireDto, +} from 'src/modules/browser/dto/string.dto'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolKeysCommands, + BrowserToolStringCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +@Injectable() +export class StringBusinessService { + private logger = new Logger('StringBusinessService'); + + constructor( + private browserTool: BrowserToolService, + private browserAnalyticsService: BrowserAnalyticsService, + ) {} + + public async setString( + clientOptions: IFindRedisClientInstanceByOptions, + dto: SetStringWithExpireDto, + ): Promise { + this.logger.log('Setting string key type.'); + const { keyName, value, expire } = dto; + let result; + try { + if (expire) { + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolStringCommands.Set, + [keyName, value, 'EX', `${expire}`, 'NX'], + ); + } else { + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolStringCommands.Set, + [keyName, value, 'NX'], + ); + } + } catch (error) { + this.logger.error('Failed to set string key type', error); + catchAclError(error); + } + if (!result) { + this.logger.error( + `Failed to set string key type. ${ERROR_MESSAGES.KEY_NAME_EXIST} key: ${keyName}`, + ); + throw new ConflictException(ERROR_MESSAGES.KEY_NAME_EXIST); + } + this.browserAnalyticsService.sendKeyAddedEvent( + clientOptions.instanceId, + RedisDataType.String, + { + length: dto.value.length, + TTL: dto.expire || -1, + }, + ); + this.logger.log('Succeed to set string key type.'); + } + + public async getStringValue( + clientOptions: IFindRedisClientInstanceByOptions, + keyName: string, + ): Promise { + this.logger.log('Getting string value.'); + let result: GetStringValueResponse; + try { + const value = await this.browserTool.execCommand( + clientOptions, + BrowserToolStringCommands.Get, + [keyName], + ); + result = { value, keyName }; + } catch (error) { + this.logger.error('Failed to get string value.', error); + if (error.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + if (result.value === null) { + this.logger.error( + `Failed to get string value. Not Found key: ${keyName}.`, + ); + throw new NotFoundException(); + } else { + this.logger.log('Succeed to get string value.'); + return result; + } + } + + public async updateStringValue( + clientOptions: IFindRedisClientInstanceByOptions, + dto: SetStringDto, + ): Promise { + this.logger.log('Updating string value.'); + const { keyName, value } = dto; + let result; + try { + const ttl = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Ttl, + [keyName], + ); + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolStringCommands.Set, + [keyName, value, 'XX'], + ); + if (result && ttl > 0) { + await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Expire, + [keyName, ttl], + ); + } + this.browserAnalyticsService.sendKeyValueEditedEvent( + clientOptions.instanceId, + RedisDataType.String, + ); + } catch (error) { + this.logger.error('Failed to update string value.', error); + catchAclError(error); + } + if (!result) { + this.logger.error( + `Failed to update string value. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + throw new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST); + } + this.logger.log('Succeed to update string value.'); + } +} diff --git a/redisinsight/api/src/modules/browser/services/z-set-business/z-set-business.service.spec.ts b/redisinsight/api/src/modules/browser/services/z-set-business/z-set-business.service.spec.ts new file mode 100644 index 0000000000..a067e54003 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/z-set-business/z-set-business.service.spec.ts @@ -0,0 +1,697 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { when } from 'jest-when'; +import { SortOrder } from 'src/constants/sort'; +import { ReplyError } from 'src/models'; +import { + mockBrowserAnalyticsService, + mockRedisConsumer, + mockRedisNoPermError, + mockRedisWrongTypeError, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import config from 'src/utils/config'; +import { + AddMembersToZSetDto, + CreateZSetWithExpireDto, + DeleteMembersFromZSetDto, + GetZSetMembersDto, + SearchZSetMembersDto, + SearchZSetMembersResponse, + UpdateMemberInZSetDto, +} from 'src/modules/browser/dto'; +import { + BrowserToolKeysCommands, + BrowserToolZSetCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { ZSetBusinessService } from './z-set-business.service'; +import { BrowserToolService } from '../browser-tool/browser-tool.service'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockGetMembersDto: GetZSetMembersDto = { + keyName: 'zSet', + offset: 0, + count: REDIS_SCAN_CONFIG.countDefault || 15, + sortOrder: SortOrder.Asc, +}; + +const mockSearchMembersDto: SearchZSetMembersDto = { + keyName: 'zSet', + cursor: 0, + count: 15, + match: '*', +}; + +const mockAddMembersDto: AddMembersToZSetDto = { + keyName: mockGetMembersDto.keyName, + members: [ + { + name: 'member1', + score: 0, + }, + { + name: 'member2', + score: 2, + }, + ], +}; + +const mockUpdateMemberDto: UpdateMemberInZSetDto = { + keyName: mockGetMembersDto.keyName, + member: mockAddMembersDto.members[0], +}; + +const mockMembersForZAddCommand = ['0', 'member1', '2', 'member2']; + +const mockDeleteMembersDto: DeleteMembersFromZSetDto = { + keyName: mockAddMembersDto.keyName, + members: ['member1', 'member2'], +}; + +const getZSetMembersInAscResponse = { + keyName: mockGetMembersDto.keyName, + total: mockAddMembersDto.members.length, + members: [...mockAddMembersDto.members], +}; + +const getZSetMembersInDescResponse = { + keyName: mockGetMembersDto.keyName, + total: mockAddMembersDto.members.length, + members: mockAddMembersDto.members.slice().reverse(), +}; + +const mockSearchZSetMembersResponse: SearchZSetMembersResponse = { + keyName: mockGetMembersDto.keyName, + total: mockAddMembersDto.members.length, + nextCursor: 0, + members: [...mockAddMembersDto.members], +}; + +describe('ZSetBusinessService', () => { + let service: ZSetBusinessService; + let browserTool; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ZSetBusinessService, + { + provide: BrowserAnalyticsService, + useFactory: mockBrowserAnalyticsService, + }, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(ZSetBusinessService); + browserTool = module.get(BrowserToolService); + }); + + describe('createZSet', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddMembersDto.keyName, + ]) + .mockResolvedValue(0); + service.createZSetWithExpiration = jest.fn(); + }); + it('create zset with expiration', async () => { + service.createZSetWithExpiration = jest + .fn() + .mockResolvedValue(mockAddMembersDto.members.length); + + await expect( + service.createZSet(mockClientOptions, { + ...mockAddMembersDto, + expire: 1000, + }), + ).resolves.not.toThrow(); + expect(service.createZSetWithExpiration).toHaveBeenCalled(); + }); + it('create zset without expiration', async () => { + const { keyName } = mockAddMembersDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZAdd, [ + keyName, + ...mockMembersForZAddCommand, + ]) + .mockResolvedValue(mockAddMembersDto.members.length); + + await expect( + service.createZSet(mockClientOptions, mockAddMembersDto), + ).resolves.not.toThrow(); + expect(service.createZSetWithExpiration).not.toHaveBeenCalled(); + }); + it('key with this name exist', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddMembersDto.keyName, + ]) + .mockResolvedValue(1); + + await expect( + service.createZSet(mockClientOptions, mockAddMembersDto), + ).rejects.toThrow(ConflictException); + expect(browserTool.execCommand).toHaveBeenCalledTimes(1); + expect(browserTool.execMulti).not.toHaveBeenCalled(); + }); + it("try to use 'ZADD' command not for zset data type for createZSet", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ZADD', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolZSetCommands.ZAdd, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.createZSet(mockClientOptions, mockAddMembersDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for createZSet", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ZADD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.createZSet(mockClientOptions, mockAddMembersDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('createZSetWithExpiration', () => { + const dto: CreateZSetWithExpireDto = { + ...mockAddMembersDto, + expire: 1000, + }; + it('succeed to create ZSet data type with expiration', async () => { + when(browserTool.execMulti) + .calledWith(mockClientOptions, [ + [ + BrowserToolZSetCommands.ZAdd, + dto.keyName, + ...mockMembersForZAddCommand, + ], + [BrowserToolKeysCommands.Expire, dto.keyName, dto.expire], + ]) + .mockResolvedValue([ + null, + [ + [null, mockAddMembersDto.members.length], + [null, 1], + ], + ]); + + const result = await service.createZSetWithExpiration( + mockClientOptions, + dto, + ); + expect(result).toBe(mockAddMembersDto.members.length); + }); + it('throw transaction error', async () => { + const transactionError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ZADD', + }; + browserTool.execMulti.mockResolvedValue([transactionError, null]); + + await expect( + service.createZSetWithExpiration(mockClientOptions, dto), + ).rejects.toEqual(transactionError); + }); + }); + + describe('getMembers', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZCard, [ + mockGetMembersDto.keyName, + ]) + .mockResolvedValue(mockAddMembersDto.members.length); + }); + it('get members sorted in asc', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolZSetCommands.ZRange, + expect.anything(), + ) + .mockResolvedValue(['member1', '0', 'member2', '2']); + + const result = await service.getMembers( + mockClientOptions, + mockGetMembersDto, + ); + await expect(result).toEqual(getZSetMembersInAscResponse); + }); + it('get members sorted in desc', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolZSetCommands.ZRevRange, + expect.anything(), + ) + .mockResolvedValue(['member2', '2', 'member1', '0']); + + const result = await service.getMembers(mockClientOptions, { + ...mockGetMembersDto, + sortOrder: SortOrder.Desc, + }); + await expect(result).toEqual(getZSetMembersInDescResponse); + }); + it('key with this name does not exist for getMembers', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZCard, [ + mockGetMembersDto.keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.getMembers(mockClientOptions, mockGetMembersDto), + ).rejects.toThrow(NotFoundException); + expect(browserTool.execCommand).toHaveBeenCalledTimes(1); + }); + it("try to use 'ZCARD' command not for zset data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ZCARD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.getMembers(mockClientOptions, mockGetMembersDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for getMembers", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ZCARD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.getMembers(mockClientOptions, mockGetMembersDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('addMembers', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddMembersDto.keyName, + ]) + .mockResolvedValue(1); + }); + it('succeed to add members to the ZSet data type', async () => { + const { keyName } = mockAddMembersDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZAdd, [ + keyName, + ...mockMembersForZAddCommand, + ]) + .mockResolvedValue(mockAddMembersDto.members.length); + + await expect( + service.addMembers(mockClientOptions, mockAddMembersDto), + ).resolves.not.toThrow(); + }); + it('key with this name does not exist for addMembers', async () => { + const { keyName } = mockAddMembersDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.addMembers(mockClientOptions, mockAddMembersDto), + ).rejects.toThrow(NotFoundException); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolZSetCommands.ZAdd, + expect.anything(), + ); + }); + it("try to use 'ZADD' command not for zset data type for addMembers", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ZADD', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolZSetCommands.ZAdd, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.addMembers(mockClientOptions, mockAddMembersDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for addMembers", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ZADD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.addMembers(mockClientOptions, mockAddMembersDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('updateMember', () => { + beforeEach(() => when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockAddMembersDto.keyName, + ]) + .mockResolvedValue(1)); + + it('succeed to update member in key', async () => { + const { keyName, member } = mockUpdateMemberDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZAdd, [ + keyName, + 'XX', + 'CH', + `${member.score}`, + member.name, + ]) + .mockResolvedValue(1); + + await expect( + service.updateMember(mockClientOptions, mockUpdateMemberDto), + ).resolves.not.toThrow(); + }); + it('key with this name does not exist for updateMember', async () => { + const { keyName } = mockUpdateMemberDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.updateMember(mockClientOptions, mockUpdateMemberDto), + ).rejects.toThrow(NotFoundException); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolZSetCommands.ZAdd, + expect.anything(), + ); + }); + it('member does not exist in key', async () => { + const { keyName, member } = mockUpdateMemberDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZAdd, [ + keyName, + 'XX', + 'CH', + `${member.score}`, + member.name, + ]) + .mockResolvedValue(0); + + await expect( + service.updateMember(mockClientOptions, mockUpdateMemberDto), + ).rejects.toThrow(NotFoundException); + }); + it("try to use 'ZADD' command not for zset data type for updateMember", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ZADD', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolZSetCommands.ZAdd, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.updateMember(mockClientOptions, mockUpdateMemberDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for updateMember", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ZADD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.updateMember(mockClientOptions, mockUpdateMemberDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('deleteMembers', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + mockDeleteMembersDto.keyName, + ]) + .mockResolvedValue(1); + }); + it('succeeded to delete members from ZSet data type', async () => { + const { members, keyName } = mockDeleteMembersDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZRem, [ + keyName, + ...members, + ]) + .mockResolvedValue(members.length); + + const result = await service.deleteMembers( + mockClientOptions, + mockDeleteMembersDto, + ); + + expect(result).toEqual({ affected: members.length }); + }); + it('key with this name does not exist for deleteMembers', async () => { + const { members, keyName } = mockDeleteMembersDto; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolKeysCommands.Exists, [ + keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.deleteMembers(mockClientOptions, mockDeleteMembersDto), + ).rejects.toThrow(NotFoundException); + expect( + browserTool.execCommand, + ).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolZSetCommands.ZRem, + [keyName, ...members], + ); + }); + it("try to use 'ZREM' command not for set data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ZREM', + }; + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolZSetCommands.ZRem, + expect.anything(), + ) + .mockRejectedValue(replyError); + + await expect( + service.deleteMembers(mockClientOptions, mockDeleteMembersDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for deleteMembers", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ZREM', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.deleteMembers(mockClientOptions, mockDeleteMembersDto), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('searchMembers', () => { + beforeEach(() => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZCard, [ + mockSearchMembersDto.keyName, + ]) + .mockResolvedValue(mockAddMembersDto.members.length); + }); + it('succeeded to search members in ZSet data type', async () => { + when(browserTool.execCommand) + .calledWith( + mockClientOptions, + BrowserToolZSetCommands.ZScan, + expect.anything(), + ) + .mockResolvedValue([0, ['member1', '0', 'member2', '2']]); + + const result = await service.searchMembers( + mockClientOptions, + mockSearchMembersDto, + ); + await expect(result).toEqual(mockSearchZSetMembersResponse); + expect(browserTool.execCommand).toHaveBeenCalledWith( + mockClientOptions, + BrowserToolZSetCommands.ZScan, + expect.anything(), + ); + }); + it('succeed to find exact member in the z-set', async () => { + const item = { name: 'member', score: 2 }; + const dto: SearchZSetMembersDto = { + ...mockSearchMembersDto, + match: item.name, + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZScore, [ + dto.keyName, + dto.match, + ]) + .mockResolvedValue(item.score); + + const result = await service.searchMembers(mockClientOptions, dto); + + expect(result).toEqual({ + ...mockSearchZSetMembersResponse, + members: [item], + }); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolZSetCommands.ZScan, + expect.anything(), + ); + }); + it('failed to find exact member in the set', async () => { + const dto: SearchZSetMembersDto = { + ...mockSearchMembersDto, + match: 'member', + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZScore, [ + dto.keyName, + dto.match, + ]) + .mockResolvedValue(null); + + const result = await service.searchMembers(mockClientOptions, dto); + + expect(result).toEqual({ ...mockSearchZSetMembersResponse, members: [] }); + }); + it('should not call scan when math contains escaped glob', async () => { + const dto: SearchZSetMembersDto = { + ...mockSearchMembersDto, + match: 'm\\[a-e\\]mber', + }; + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZScore, [ + dto.keyName, + 'm[a-e]mber', + ]) + .mockResolvedValue(1); + + const result = await service.searchMembers(mockClientOptions, dto); + + expect(result).toEqual({ + ...mockSearchZSetMembersResponse, + members: [{ name: 'm[a-e]mber', score: 1 }], + }); + expect(browserTool.execCommand).not.toHaveBeenCalledWith( + mockClientOptions, + BrowserToolZSetCommands.ZScan, + expect.anything(), + ); + }); + // TODO: uncomment after enabling threshold for z-set scan + // it('should stop z-set full scan', async () => { + // const dto: SearchZSetMembersDto = { + // ...mockSearchMembersDto, + // count: REDIS_SCAN_CONFIG.countDefault, + // match: '*un-exist-member*', + // }; + // const maxScanCalls = Math.round( + // REDIS_SCAN_CONFIG.countThreshold / REDIS_SCAN_CONFIG.countDefault, + // ); + // when(browserTool.execCommand) + // .calledWith( + // mockClientOptions, + // BrowserToolZSetCommands.ZScan, + // expect.anything(), + // ) + // .mockResolvedValue(['200', []]); + // + // await service.searchMembers(mockClientOptions, dto); + // + // expect(browserTool.execCommand).toHaveBeenCalledTimes(maxScanCalls + 1); + // }); + it('key with this name does not exist for searchMembers', async () => { + when(browserTool.execCommand) + .calledWith(mockClientOptions, BrowserToolZSetCommands.ZCard, [ + mockSearchMembersDto.keyName, + ]) + .mockResolvedValue(0); + + await expect( + service.searchMembers(mockClientOptions, mockSearchMembersDto), + ).rejects.toThrow(NotFoundException); + expect(browserTool.execCommand).toHaveBeenCalledTimes(1); + }); + it("try to use 'ZCARD' command not for zset data type", async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + command: 'ZCARD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.searchMembers(mockClientOptions, mockSearchMembersDto), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions for searchMembers", async () => { + const replyError: ReplyError = { + ...mockRedisNoPermError, + command: 'ZCARD', + }; + browserTool.execCommand.mockRejectedValue(replyError); + + await expect( + service.searchMembers(mockClientOptions, mockSearchMembersDto), + ).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/z-set-business/z-set-business.service.ts b/redisinsight/api/src/modules/browser/services/z-set-business/z-set-business.service.ts new file mode 100644 index 0000000000..ffaebf1f0e --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/z-set-business/z-set-business.service.ts @@ -0,0 +1,474 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { isNull } from 'lodash'; +import * as isGlob from 'is-glob'; +import config from 'src/utils/config'; +import { catchAclError, catchTransactionError, unescapeGlob } from 'src/utils'; +import { + AddMembersToZSetDto, + CreateZSetWithExpireDto, + DeleteMembersFromZSetDto, + DeleteMembersFromZSetResponse, + GetZSetMembersDto, + GetZSetResponse, + RedisDataType, + ScanZSetResponse, + SearchZSetMembersDto, + SearchZSetMembersResponse, + UpdateMemberInZSetDto, + ZSetMemberDto, +} from 'src/modules/browser/dto'; +import { SortOrder } from 'src/constants/sort'; +import { RedisErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { ReplyError } from 'src/models'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { + BrowserToolKeysCommands, + BrowserToolZSetCommands, +} from 'src/modules/browser/constants/browser-tool-commands'; +import { BrowserAnalyticsService } from '../browser-analytics/browser-analytics.service'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +@Injectable() +export class ZSetBusinessService { + private logger = new Logger('ZSetBusinessService'); + + constructor( + private browserTool: BrowserToolService, + private browserAnalyticsService: BrowserAnalyticsService, + ) {} + + public async createZSet( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateZSetWithExpireDto, + ): Promise { + this.logger.log('Creating ZSet data type.'); + const { keyName } = dto; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (isExist) { + this.logger.error( + `Failed to create ZSet data type. ${ERROR_MESSAGES.KEY_NAME_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new ConflictException(ERROR_MESSAGES.KEY_NAME_EXIST), + ); + } + if (dto.expire) { + await this.createZSetWithExpiration(clientOptions, dto); + } else { + await this.createSimpleZSet(clientOptions, dto); + } + this.browserAnalyticsService.sendKeyAddedEvent( + clientOptions.instanceId, + RedisDataType.ZSet, + { + length: dto.members.length, + TTL: dto.expire || -1, + }, + ); + this.logger.log('Succeed to create ZSet data type.'); + } catch (error) { + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + this.logger.error('Failed to create ZSet data type.', error); + catchAclError(error); + } + return null; + } + + public async getMembers( + clientOptions: IFindRedisClientInstanceByOptions, + getZSetDto: GetZSetMembersDto, + ): Promise { + this.logger.log('Getting members of the ZSet data type stored at key.'); + const { keyName, sortOrder } = getZSetDto; + let result: GetZSetResponse; + try { + const total = await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZCard, + [keyName], + ); + if (!total) { + this.logger.error( + `Failed to get members of the ZSet data type. Not Found key: ${keyName}.`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + let members: ZSetMemberDto[] = []; + + if (sortOrder && sortOrder === SortOrder.Asc) { + members = await this.getZRange(clientOptions, getZSetDto); + } else { + members = await this.getZRevRange(clientOptions, getZSetDto); + } + + this.logger.log('Succeed to get members of the ZSet data type.'); + result = { + keyName, + total, + members, + }; + } catch (error) { + this.logger.error('Failed to get members of the ZSet data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + return result; + } + + public async addMembers( + clientOptions: IFindRedisClientInstanceByOptions, + dto: AddMembersToZSetDto, + ): Promise { + this.logger.log('Adding members to the ZSet data type.'); + const { keyName, members } = dto; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to add members to ZSet data type. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + const args = this.formatMembersDtoToCommandArgs(members); + const added = await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZAdd, + [keyName, ...args], + ); + if (added) { + this.browserAnalyticsService.sendKeyValueAddedEvent( + clientOptions.instanceId, + RedisDataType.ZSet, + { + numberOfAdded: added, + }, + ); + } + if (members.length - added > 0) { + this.browserAnalyticsService.sendKeyValueEditedEvent( + clientOptions.instanceId, + RedisDataType.ZSet, + ); + } + this.logger.log('Succeed to add members to ZSet data type.'); + } catch (error) { + this.logger.error('Failed to add members to Set data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + return null; + } + + public async updateMember( + clientOptions: IFindRedisClientInstanceByOptions, + dto: UpdateMemberInZSetDto, + ): Promise { + this.logger.log('Updating member in ZSet data type.'); + const { keyName, member } = dto; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to update member in ZSet data type. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + const result = await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZAdd, + [keyName, 'XX', 'CH', `${member.score}`, member.name], + ); + if (!result) { + this.logger.error( + `Failed to update member in ZSet data type. ${ERROR_MESSAGES.MEMBER_IN_SET_NOT_EXIST}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.MEMBER_IN_SET_NOT_EXIST), + ); + } + if (result) { + this.browserAnalyticsService.sendKeyValueEditedEvent( + clientOptions.instanceId, + RedisDataType.ZSet, + ); + } + this.logger.log('Succeed to update member in ZSet data type.'); + } catch (error) { + this.logger.error('Failed to update member in ZSet data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + return null; + } + + public async deleteMembers( + clientOptions: IFindRedisClientInstanceByOptions, + dto: DeleteMembersFromZSetDto, + ): Promise { + this.logger.log('Deleting members from the ZSet data type.'); + const { keyName, members } = dto; + let result; + try { + const isExist = await this.browserTool.execCommand( + clientOptions, + BrowserToolKeysCommands.Exists, + [keyName], + ); + if (!isExist) { + this.logger.error( + `Failed to delete members from the ZSet data type. ${ERROR_MESSAGES.KEY_NOT_EXIST} key: ${keyName}`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + result = await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZRem, + [keyName, ...members], + ); + if (result) { + this.browserAnalyticsService.sendKeyValueRemovedEvent( + clientOptions.instanceId, + RedisDataType.ZSet, + { + numberOfRemoved: result, + }, + ); + } + } catch (error) { + this.logger.error('Failed to delete members from the ZSet data type.', error); + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + catchAclError(error); + } + this.logger.log('Succeed to delete members from the ZSet data type.'); + return { affected: result }; + } + + public async searchMembers( + clientOptions: IFindRedisClientInstanceByOptions, + dto: SearchZSetMembersDto, + ): Promise { + this.logger.log('Search members of the ZSet data type stored at key.'); + const { keyName } = dto; + let result: SearchZSetMembersResponse = { + keyName, + total: 0, + members: [], + nextCursor: dto.cursor, + }; + try { + result.total = await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZCard, + [keyName], + ); + if (!result.total) { + this.logger.error( + `Failed to search members of the ZSet data type. Not Found key: ${keyName}.`, + ); + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.KEY_NOT_EXIST), + ); + } + if (dto.match && !isGlob(dto.match, { strict: false })) { + const member = unescapeGlob(dto.match); + result.nextCursor = 0; + const score = await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZScore, + [keyName, member], + ); + if (!isNull(score)) { + result.members.push({ name: member, score }); + } + } else { + const scanResult = await this.scanZSet(clientOptions, dto); + result = { ...result, ...scanResult }; + } + this.browserAnalyticsService.sendKeyScannedEvent( + clientOptions.instanceId, + RedisDataType.ZSet, + dto.match, + { + length: result.total, + }, + ); + this.logger.log('Succeed to search members of the ZSet data type.'); + return result; + } catch (error) { + this.logger.error('Failed to search members of the ZSet data type.', error); + + if (error?.message.includes(RedisErrorCodes.WrongType)) { + throw new BadRequestException(error.message); + } + + throw catchAclError(error); + } + } + + public async getZRange( + clientOptions: IFindRedisClientInstanceByOptions, + getZSetDto: GetZSetMembersDto, + ): Promise { + const { keyName, offset, count } = getZSetDto; + + const execResult = await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZRange, + [keyName, offset, offset + count - 1, 'WITHSCORES'], + ); + + return this.formatZRangeWithScoresReply(execResult); + } + + public async getZRevRange( + clientOptions: IFindRedisClientInstanceByOptions, + getZSetDto: GetZSetMembersDto, + ): Promise { + const { keyName, offset, count } = getZSetDto; + + const execResult = await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZRevRange, + [keyName, offset, offset + count - 1, 'WITHSCORES'], + ); + + return this.formatZRangeWithScoresReply(execResult); + } + + public async createSimpleZSet( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateZSetWithExpireDto, + ): Promise { + const { keyName, members } = dto; + const args = this.formatMembersDtoToCommandArgs(members); + + return await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZAdd, + [keyName, ...args], + ); + } + + public async createZSetWithExpiration( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateZSetWithExpireDto, + ): Promise { + const { keyName, members, expire } = dto; + + const args = this.formatMembersDtoToCommandArgs(members); + const [ + transactionError, + transactionResults, + ] = await this.browserTool.execMulti(clientOptions, [ + [BrowserToolZSetCommands.ZAdd, keyName, ...args], + [BrowserToolKeysCommands.Expire, keyName, expire], + ]); + catchTransactionError(transactionError, transactionResults); + const execResult = transactionResults.map( + (item: [ReplyError, any]) => item[1], + ); + const [added] = execResult; + return added; + } + + public async scanZSet( + clientOptions: IFindRedisClientInstanceByOptions, + dto: SearchZSetMembersDto, + ): Promise { + const { keyName } = dto; + const count = dto.count || REDIS_SCAN_CONFIG.countDefault; + const match = dto.match !== undefined ? dto.match : '*'; + let result: ScanZSetResponse = { + keyName, + nextCursor: null, + members: [], + }; + + while (result.nextCursor !== 0 && result.members.length < count) { + const scanResult = await this.browserTool.execCommand( + clientOptions, + BrowserToolZSetCommands.ZScan, + [ + keyName, + `${result.nextCursor || dto.cursor}`, + 'MATCH', + match, + 'COUNT', + count, + ], + ); + const [nextCursor, membersArray] = scanResult; + const members: ZSetMemberDto[] = this.formatZRangeWithScoresReply( + membersArray, + ); + result = { + ...result, + nextCursor: parseInt(nextCursor, 10), + members: [...result.members, ...members], + }; + } + return result; + } + + private formatZRangeWithScoresReply(reply: string[]): ZSetMemberDto[] { + const result: ZSetMemberDto[] = []; + while (reply.length) { + const member = reply.splice(0, 2); + result.push({ + name: member[0], + score: parseFloat(member[1]), + }); + } + return result; + } + + private formatMembersDtoToCommandArgs(members: ZSetMemberDto[]): string[] { + return members.reduce( + (prev: string[], cur: ZSetMemberDto) => [ + ...prev, + ...[`${cur.score}`, `${cur.name}`], + ], + [], + ); + } +} diff --git a/redisinsight/api/src/modules/cli/cli.module.ts b/redisinsight/api/src/modules/cli/cli.module.ts new file mode 100644 index 0000000000..1a91bfadde --- /dev/null +++ b/redisinsight/api/src/modules/cli/cli.module.ts @@ -0,0 +1,26 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { RouterModule } from 'nest-router'; +import { SharedModule } from 'src/modules/shared/shared.module'; +import { RedisConnectionMiddleware } from 'src/middleware/redis-connection.middleware'; +import { CliController } from './controllers/cli.controller'; +import { CliBusinessService } from './services/cli-business/cli-business.service'; +import { CliToolService } from './services/cli-tool/cli-tool.service'; +import { CliAnalyticsService } from './services/cli-analytics/cli-analytics.service'; + +@Module({ + imports: [SharedModule], + controllers: [CliController], + providers: [ + CliBusinessService, + CliToolService, + CliAnalyticsService, + ], +}) +export class CliModule implements NestModule { + // eslint-disable-next-line class-methods-use-this + configure(consumer: MiddlewareConsumer): any { + consumer + .apply(RedisConnectionMiddleware) + .forRoutes(RouterModule.resolvePath(CliController)); + } +} diff --git a/redisinsight/api/src/modules/cli/constants/errors.ts b/redisinsight/api/src/modules/cli/constants/errors.ts new file mode 100644 index 0000000000..7eeafce7e1 --- /dev/null +++ b/redisinsight/api/src/modules/cli/constants/errors.ts @@ -0,0 +1,36 @@ +import { ReplyError } from 'src/models'; + +export class CliParsingError extends ReplyError { + constructor(args) { + super(args); + this.name = 'CliParsingError'; + } +} + +export class RedirectionParsingError extends ReplyError { + constructor(args = 'Could not parse redirection error.') { + super(args); + this.name = 'RedirectionParsingError'; + } +} + +export class CliCommandNotSupportedError extends ReplyError { + constructor(args) { + super(args); + this.name = 'CliCommandNotSupportedError'; + } +} + +export class WrongDatabaseTypeError extends Error { + constructor(args) { + super(args); + this.name = 'WrongDatabaseTypeError'; + } +} + +export class ClusterNodeNotFoundError extends Error { + constructor(args) { + super(args); + this.name = 'ClusterNodeNotFoundError'; + } +} diff --git a/redisinsight/api/src/modules/cli/controllers/cli.controller.ts b/redisinsight/api/src/modules/cli/controllers/cli.controller.ts new file mode 100644 index 0000000000..974006cdf9 --- /dev/null +++ b/redisinsight/api/src/modules/cli/controllers/cli.controller.ts @@ -0,0 +1,138 @@ +import { + Body, + Controller, + Delete, + Param, + Patch, + Post, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { + CreateCliClientResponse, + DeleteClientResponse, + SendClusterCommandResponse, + SendClusterCommandDto, + SendCommandDto, + SendCommandResponse, + CreateCliClientDto, +} from 'src/modules/cli/dto/cli.dto'; +import { CliBusinessService } from 'src/modules/cli/services/cli-business/cli-business.service'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { ApiCLIParams } from 'src/modules/cli/decorators/api-cli-params.decorator'; + +@ApiTags('CLI') +@Controller('cli') +@UsePipes(new ValidationPipe({ transform: true })) +export class CliController { + constructor(private service: CliBusinessService) {} + + @Post('') + @ApiCLIParams(false) + @ApiEndpoint({ + description: 'Create Redis client for CLI', + statusCode: 201, + responses: [ + { + status: 201, + description: 'Create Redis client for CLI', + type: CreateCliClientResponse, + }, + ], + }) + async getClient( + @Param('dbInstance') dbInstance: string, + @Body() dto: CreateCliClientDto, + ): Promise { + return this.service.getClient(dbInstance, dto.namespace); + } + + @Post('/:uuid/send-command') + @ApiCLIParams() + @ApiEndpoint({ + description: 'Send Redis CLI command', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Redis CLI command response', + type: SendCommandResponse, + }, + ], + }) + async sendCommand( + @Param('dbInstance') dbInstance: string, + @Param('uuid') uuid: string, + @Body() dto: SendCommandDto, + ): Promise { + return this.service.sendCommand( + { + instanceId: dbInstance, + uuid, + }, + dto, + ); + } + + @Post('/:uuid/send-cluster-command') + @ApiCLIParams() + @ApiEndpoint({ + description: 'Send Redis CLI command', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Redis CLI command response', + type: SendClusterCommandResponse, + isArray: true, + }, + ], + }) + async sendClusterCommand( + @Param('dbInstance') dbInstance: string, + @Param('uuid') uuid: string, + @Body() dto: SendClusterCommandDto, + ): Promise { + return this.service.sendClusterCommand( + { + instanceId: dbInstance, + uuid, + }, + dto, + ); + } + + @Delete('/:uuid') + @ApiCLIParams() + @ApiEndpoint({ + description: 'Delete Redis CLI client', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Delete Redis CLI client response', + type: DeleteClientResponse, + }, + ], + }) + async deleteClient( + @Param('dbInstance') dbInstance: string, + @Param('uuid') uuid: string, + ): Promise { + return this.service.deleteClient(dbInstance, uuid); + } + + @Patch('/:uuid') + @ApiCLIParams() + @ApiEndpoint({ + description: 'Re-create Redis client for CLI', + statusCode: 200, + }) + async reCreateClient( + @Param('dbInstance') dbInstance: string, + @Param('uuid') uuid: string, + ): Promise { + return this.service.reCreateClient(dbInstance, uuid); + } +} diff --git a/redisinsight/api/src/modules/cli/decorators/api-cli-params.decorator.ts b/redisinsight/api/src/modules/cli/decorators/api-cli-params.decorator.ts new file mode 100644 index 0000000000..f906f961c8 --- /dev/null +++ b/redisinsight/api/src/modules/cli/decorators/api-cli-params.decorator.ts @@ -0,0 +1,26 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiParam } from '@nestjs/swagger'; + +export function ApiCLIParams( + requireClientUuid: boolean = true, +): MethodDecorator & ClassDecorator { + const decorators = [ + ApiParam({ + name: 'dbInstance', + description: 'Database instance id.', + type: String, + required: true, + }), + ]; + if (requireClientUuid) { + decorators.push( + ApiParam({ + name: 'uuid', + description: 'CLI client uuid', + type: String, + required: true, + }), + ); + } + return applyDecorators(...decorators); +} diff --git a/redisinsight/api/src/modules/cli/dto/cli.dto.ts b/redisinsight/api/src/modules/cli/dto/cli.dto.ts new file mode 100644 index 0000000000..0b66cde74c --- /dev/null +++ b/redisinsight/api/src/modules/cli/dto/cli.dto.ts @@ -0,0 +1,153 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsDefined, + IsEnum, + IsNotEmpty, + IsNotEmptyObject, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { EndpointDto } from 'src/modules/instances/dto/database-instance.dto'; +import { + CliOutputFormatterTypes, +} from 'src/modules/cli/services/cli-business/output-formatter/output-formatter.interface'; + +export enum CommandExecutionStatus { + Success = 'success', + Fail = 'fail', +} + +export enum ClusterNodeRole { + All = 'ALL', + Master = 'MASTER', + Slave = 'SLAVE', +} + +export class CreateCliClientDto { + @ApiPropertyOptional({ + type: String, + example: 'workbench', + description: 'This namespace will be used in Redis client connection name', + }) + @IsString() + @IsOptional() + @MaxLength(50) + @IsNotEmpty() + namespace: string; +} + +export class SendCommandDto { + @ApiProperty({ + type: String, + description: 'Redis CLI command', + }) + @IsString() + @IsNotEmpty() + command: string; + + @ApiPropertyOptional({ + description: 'Define output format', + default: CliOutputFormatterTypes.Text, + enum: CliOutputFormatterTypes, + }) + @IsOptional() + @IsEnum(CliOutputFormatterTypes, { + message: `outputFormat must be a valid enum value. Valid values: ${Object.values( + CliOutputFormatterTypes, + )}.`, + }) + outputFormat?: CliOutputFormatterTypes; +} + +export class ClusterSingleNodeOptions extends EndpointDto { + @ApiProperty({ + description: 'Use redirects for OSS Cluster or not.', + type: Boolean, + default: true, + }) + @IsBoolean() + @IsDefined() + enableRedirection: boolean; +} + +export class SendClusterCommandDto extends SendCommandDto { + @ApiProperty({ + description: 'Execute command for nodes with defined role', + default: ClusterNodeRole.All, + enum: ClusterNodeRole, + }) + @IsDefined() + @IsEnum(ClusterNodeRole, { + message: `role must be a valid enum value. Valid values: ${Object.values( + ClusterNodeRole, + )}.`, + }) + role: ClusterNodeRole; + + @ApiPropertyOptional({ + description: + 'Should be provided if only one node needs to execute the command.', + type: ClusterSingleNodeOptions, + }) + @IsOptional() + @IsNotEmptyObject() + @Type(() => ClusterSingleNodeOptions) + @ValidateNested() + nodeOptions?: ClusterSingleNodeOptions; +} + +export class SendCommandResponse { + @ApiProperty({ + type: String, + description: 'Redis CLI response', + }) + response: any; + + @ApiProperty({ + description: 'Redis CLI command execution status', + default: CommandExecutionStatus.Success, + enum: CommandExecutionStatus, + }) + status: CommandExecutionStatus; +} + +export class SendClusterCommandResponse { + @ApiProperty({ + type: String, + description: 'Redis CLI response', + }) + response: any; + + @ApiPropertyOptional({ + type: () => EndpointDto, + description: 'Redis Cluster Node info', + }) + node?: EndpointDto; + + @ApiProperty({ + description: 'Redis CLI command execution status', + default: CommandExecutionStatus.Success, + enum: CommandExecutionStatus, + }) + status: CommandExecutionStatus; +} + +export class CreateCliClientResponse { + @ApiProperty({ + type: String, + description: 'Client uuid', + }) + uuid: string; +} + +export class DeleteClientResponse { + @ApiProperty({ + description: 'Number of affected clients', + type: Number, + }) + affected: number; +} diff --git a/redisinsight/api/src/modules/cli/services/cli-analytics/cli-analytics.service.spec.ts b/redisinsight/api/src/modules/cli/services/cli-analytics/cli-analytics.service.spec.ts new file mode 100644 index 0000000000..2aec654282 --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-analytics/cli-analytics.service.spec.ts @@ -0,0 +1,313 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InternalServerErrorException } from '@nestjs/common'; +import { mockRedisWrongTypeError, mockStandaloneDatabaseEntity } from 'src/__mocks__'; +import { TelemetryEvents } from 'src/constants'; +import { ReplyError } from 'src/models'; +import { CliParsingError } from 'src/modules/cli/constants/errors'; +import { ICliExecResultFromNode } from 'src/modules/cli/services/cli-tool/cli-tool.service'; +import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; +import { CliAnalyticsService } from './cli-analytics.service'; + +const redisReplyError: ReplyError = { + ...mockRedisWrongTypeError, + command: { name: 'sadd' }, +}; +const instanceId = mockStandaloneDatabaseEntity.id; +const httpException = new InternalServerErrorException(); + +describe('CliAnalyticsService', () => { + let service: CliAnalyticsService; + let sendEventMethod: jest.SpyInstance; + let sendFailedEventMethod: jest.SpyInstance; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + CliAnalyticsService, + ], + }).compile(); + + service = module.get(CliAnalyticsService); + sendEventMethod = jest.spyOn( + service, + 'sendEvent', + ); + sendFailedEventMethod = jest.spyOn( + service, + 'sendFailedEvent', + ); + }); + + describe('sendCliClientCreatedEvent', () => { + it('should emit CliClientCreated event', () => { + service.sendCliClientCreatedEvent(instanceId, { data: 'Some data' }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientCreated, + { + databaseId: instanceId, + data: 'Some data', + }, + ); + }); + it('should emit CliClientCreated event without additional data', () => { + service.sendCliClientCreatedEvent(instanceId); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientCreated, + { + databaseId: instanceId, + }, + ); + }); + }); + + describe('sendCliClientCreationFailedEvent', () => { + it('should emit CliClientCreationFailed event', () => { + service.sendCliClientCreationFailedEvent(instanceId, httpException, { data: 'Some data' }); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientCreationFailed, + httpException, + { + databaseId: instanceId, + data: 'Some data', + }, + ); + }); + it('should emit CliClientCreationFailed event without additional data', () => { + service.sendCliClientCreationFailedEvent(instanceId, httpException); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientCreationFailed, + httpException, + { + databaseId: instanceId, + }, + ); + }); + }); + + describe('sendCliClientRecreatedEvent', () => { + it('should emit CliClientRecreated event', () => { + service.sendCliClientRecreatedEvent(instanceId, { data: 'Some data' }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientRecreated, + { + databaseId: instanceId, + data: 'Some data', + }, + ); + }); + it('should emit CliClientRecreated event without additional data', () => { + service.sendCliClientRecreatedEvent(instanceId); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientRecreated, + { + databaseId: instanceId, + }, + ); + }); + }); + + describe('sendCliClientDeletedEvent', () => { + it('should emit CliClientDeleted event', () => { + service.sendCliClientDeletedEvent(1, instanceId, { data: 'Some data' }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientDeleted, + { + databaseId: instanceId, + data: 'Some data', + }, + ); + }); + it('should emit CliClientDeleted event without additional data', () => { + service.sendCliClientDeletedEvent(1, instanceId); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientDeleted, + { + databaseId: instanceId, + }, + ); + }); + it('should not emit event', () => { + service.sendCliClientDeletedEvent(0, instanceId); + + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + it('should not emit event on invalid input values', () => { + const input: any = {}; + service.sendCliClientDeletedEvent(input, instanceId); + + expect(() => service.sendCliClientDeletedEvent(input, instanceId)).not.toThrow(); + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); + + describe('sendCliCommandExecutedEvent', () => { + it('should emit CliCommandExecuted event', () => { + service.sendCliCommandExecutedEvent(instanceId, { command: 'info' }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliCommandExecuted, + { + databaseId: instanceId, + command: 'info', + }, + ); + }); + it('should emit CliCommandExecuted event without additional data', () => { + service.sendCliCommandExecutedEvent(instanceId); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliCommandExecuted, + { + databaseId: instanceId, + }, + ); + }); + }); + + describe('sendCliCommandErrorEvent', () => { + it('should emit CliCommandError event', () => { + service.sendCliCommandErrorEvent(instanceId, redisReplyError, { data: 'Some data' }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliCommandErrorReceived, + { + databaseId: instanceId, + error: ReplyError.name, + command: 'sadd', + data: 'Some data', + }, + ); + }); + it('should emit CliCommandError event without additional data', () => { + service.sendCliCommandErrorEvent(instanceId, redisReplyError); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliCommandErrorReceived, + { + databaseId: instanceId, + error: ReplyError.name, + command: 'sadd', + }, + ); + }); + it('should emit event for custom error', () => { + const error: any = CliParsingError; + service.sendCliCommandErrorEvent(instanceId, error); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliCommandErrorReceived, + { + databaseId: instanceId, + error: CliParsingError.name, + }, + ); + }); + }); + + describe('sendCliClientCreationFailedEvent', () => { + it('should emit CliConnectionError event', () => { + service.sendCliConnectionErrorEvent(instanceId, httpException, { data: 'Some data' }); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientConnectionError, + httpException, + { + databaseId: instanceId, + data: 'Some data', + }, + ); + }); + it('should emit CliConnectionError event without additional data', () => { + service.sendCliConnectionErrorEvent(instanceId, httpException); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClientConnectionError, + httpException, + { + databaseId: instanceId, + }, + ); + }); + }); + + describe('sendCliClusterCommandExecutedEvent', () => { + it('should emit success event', () => { + const nodExecResult: ICliExecResultFromNode = { + response: '(integer) 5', + host: '127.0.0.1', + port: 7002, + status: CommandExecutionStatus.Success, + }; + + service.sendCliClusterCommandExecutedEvent(instanceId, nodExecResult, { command: 'sadd' }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliClusterNodeCommandExecuted, + { + databaseId: instanceId, + command: 'sadd', + }, + ); + }); + it('should emit event failed event for [RedisReply] error', () => { + const nodExecResult: ICliExecResultFromNode = { + response: redisReplyError.message, + host: '127.0.0.1', + port: 7002, + error: redisReplyError, + status: CommandExecutionStatus.Fail, + }; + + service.sendCliClusterCommandExecutedEvent(instanceId, nodExecResult); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliCommandErrorReceived, + { + databaseId: instanceId, + error: redisReplyError.name, + command: 'sadd', + }, + ); + }); + it('should emit event failed for custom error', () => { + const nodExecResult: ICliExecResultFromNode = { + response: redisReplyError.message, + host: '127.0.0.1', + port: 7002, + error: CliParsingError, + status: CommandExecutionStatus.Fail, + }; + + service.sendCliClusterCommandExecutedEvent(instanceId, nodExecResult); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.CliCommandErrorReceived, + { + databaseId: instanceId, + error: CliParsingError.name, + }, + ); + }); + it('should not emit event event', () => { + const nodExecResult: any = { + response: redisReplyError.message, + host: '127.0.0.1', + port: 7002, + status: 'undefined status', + }; + service.sendCliClusterCommandExecutedEvent(instanceId, nodExecResult); + + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/cli/services/cli-analytics/cli-analytics.service.ts b/redisinsight/api/src/modules/cli/services/cli-analytics/cli-analytics.service.ts new file mode 100644 index 0000000000..a0c67651c9 --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-analytics/cli-analytics.service.ts @@ -0,0 +1,145 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { TelemetryBaseService } from 'src/modules/shared/services/base/telemetry.base.service'; +import { ReplyError } from 'src/models'; +import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; +import { ICliExecResultFromNode } from 'src/modules/cli/services/cli-tool/cli-tool.service'; + +@Injectable() +export class CliAnalyticsService extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendCliClientCreatedEvent(instanceId: string, additionalData: object = {}): void { + this.sendEvent( + TelemetryEvents.CliClientCreated, + { + databaseId: instanceId, + ...additionalData, + }, + ); + } + + sendCliClientCreationFailedEvent( + instanceId: string, + exception: HttpException, + additionalData: object = {}, + ): void { + this.sendFailedEvent( + TelemetryEvents.CliClientCreationFailed, + exception, + { + databaseId: instanceId, + ...additionalData, + }, + ); + } + + sendCliClientRecreatedEvent(instanceId: string, additionalData: object = {}): void { + this.sendEvent( + TelemetryEvents.CliClientRecreated, + { + databaseId: instanceId, + ...additionalData, + }, + ); + } + + sendCliClientDeletedEvent( + affected: number, + instanceId: string, + additionalData: object = {}, + ): void { + try { + if (affected > 0) { + this.sendEvent( + TelemetryEvents.CliClientDeleted, + { + databaseId: instanceId, + ...additionalData, + }, + ); + } + } catch (e) { + // continue regardless of error + } + } + + sendCliCommandExecutedEvent(instanceId: string, additionalData: object = {}): void { + this.sendEvent( + TelemetryEvents.CliCommandExecuted, + { + databaseId: instanceId, + ...additionalData, + }, + ); + } + + sendCliClusterCommandExecutedEvent( + instanceId: string, + result: ICliExecResultFromNode, + additionalData: object = {}, + ): void { + const { status, error } = result; + try { + if (status === CommandExecutionStatus.Success) { + this.sendEvent( + TelemetryEvents.CliClusterNodeCommandExecuted, + { + databaseId: instanceId, + ...additionalData, + }, + ); + } + if (status === CommandExecutionStatus.Fail) { + this.sendEvent( + TelemetryEvents.CliCommandErrorReceived, + { + databaseId: instanceId, + error: error.name, + command: error?.command?.name, + }, + ); + } + } catch (e) { + // continue regardless of error + } + } + + sendCliCommandErrorEvent( + instanceId: string, + error: ReplyError, + additionalData: object = {}, + ): void { + try { + this.sendEvent( + TelemetryEvents.CliCommandErrorReceived, + { + databaseId: instanceId, + error: error?.name, + command: error?.command?.name, + ...additionalData, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendCliConnectionErrorEvent( + instanceId: string, + exception: HttpException, + additionalData: object = {}, + ): void { + this.sendFailedEvent( + TelemetryEvents.CliClientConnectionError, + exception, + { + databaseId: instanceId, + ...additionalData, + }, + ); + } +} diff --git a/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.spec.ts b/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.spec.ts new file mode 100644 index 0000000000..0d181597f2 --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.spec.ts @@ -0,0 +1,713 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; +import { get } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; +import { when } from 'jest-when'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + mockRedisServerInfoResponse, + mockRedisWrongTypeError, + mockStandaloneDatabaseEntity, + mockCliAnalyticsService, + mockRedisMovedError, +} from 'src/__mocks__'; +import { + ClusterNodeRole, + CommandExecutionStatus, + SendClusterCommandDto, + SendClusterCommandResponse, + SendCommandDto, + SendCommandResponse, +} from 'src/modules/cli/dto/cli.dto'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { ReplyError } from 'src/models'; +import { CliToolUnsupportedCommands } from 'src/utils/cli-helper'; +import { EndpointDto } from 'src/modules/instances/dto/database-instance.dto'; +import { ClusterNodeNotFoundError, WrongDatabaseTypeError } from 'src/modules/cli/constants/errors'; +import { CliAnalyticsService } from 'src/modules/cli/services/cli-analytics/cli-analytics.service'; +import { KeytarUnavailableException } from 'src/modules/core/encryption/exceptions'; +import { OutputFormatterManager } from './output-formatter/output-formatter-manager'; +import { CliOutputFormatterTypes, IOutputFormatterStrategy } from './output-formatter/output-formatter.interface'; +import { CliToolService } from '../cli-tool/cli-tool.service'; +import { CliBusinessService } from './cli-business.service'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; +const mockClientUuid = uuidv4(); +const mockNode: EndpointDto = { + host: '127.0.0.1', + port: 7002, +}; + +const mockRedisConsumer = () => ({ + execCommand: jest.fn(), + execCommandForNode: jest.fn(), + execCommandForNodes: jest.fn(), + execPipeline: jest.fn(), + createNewToolClient: jest.fn(), + reCreateToolClient: jest.fn(), + deleteToolClient: jest.fn(), +}); + +const mockENotFoundMessage = 'ENOTFOUND some message'; +const mockMemoryUsageCommand = 'memory usage key'; +const mockGetEscapedKeyCommand = 'get "\\\\key'; +const mockServerInfoCommand = 'info server'; +const mockIntegerResponse = '(integer) 5'; + +describe('CliBusinessService', () => { + let service: CliBusinessService; + let cliTool; + let textFormatter: IOutputFormatterStrategy; + let rawFormatter: IOutputFormatterStrategy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CliBusinessService, + { + provide: CliAnalyticsService, + useFactory: mockCliAnalyticsService, + }, + { + provide: CliToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(CliBusinessService); + cliTool = module.get(CliToolService); + const outputFormatterManager: OutputFormatterManager = get( + service, + 'outputFormatterManager', + ); + textFormatter = outputFormatterManager.getStrategy( + CliOutputFormatterTypes.Text, + ); + rawFormatter = outputFormatterManager.getStrategy( + CliOutputFormatterTypes.Raw, + ); + }); + + describe('getClient', () => { + it('should successfully create new redis client', async () => { + cliTool.createNewToolClient.mockResolvedValue(mockClientUuid); + + const result = await service.getClient(mockStandaloneDatabaseEntity.id); + + expect(result).toEqual({ uuid: mockClientUuid }); + }); + + it('should throw internal exception on getClient error', async () => { + cliTool.createNewToolClient.mockRejectedValue( + new InternalServerErrorException(mockENotFoundMessage), + ); + + try { + await service.getClient(mockStandaloneDatabaseEntity.id); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + + it('Should proxy EncryptionService errors on getClient', async () => { + cliTool.createNewToolClient.mockRejectedValue(new KeytarUnavailableException()); + + try { + await service.getClient(mockStandaloneDatabaseEntity.id); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(KeytarUnavailableException); + } + }); + }); + + describe('reCreateClient', () => { + it('should successfully create new redis client', async () => { + cliTool.reCreateToolClient.mockResolvedValue(mockClientUuid); + + const result = await service.reCreateClient( + mockStandaloneDatabaseEntity.id, + mockClientUuid, + ); + + expect(result).toEqual({ uuid: mockClientUuid }); + }); + + it('should throw internal exception on reCreateClient', async () => { + cliTool.reCreateToolClient.mockRejectedValue( + new InternalServerErrorException(mockENotFoundMessage), + ); + + try { + await service.reCreateClient( + mockStandaloneDatabaseEntity.id, + mockClientUuid, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + + it('Should proxy EncryptionService errors on reCreateClient', async () => { + cliTool.reCreateToolClient.mockRejectedValue(new KeytarUnavailableException()); + + try { + await service.reCreateClient( + mockStandaloneDatabaseEntity.id, + mockClientUuid, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(KeytarUnavailableException); + } + }); + }); + + describe('deleteClient', () => { + it('should successfully close redis client', async () => { + cliTool.deleteToolClient.mockResolvedValue(1); + + const result = await service.deleteClient( + mockStandaloneDatabaseEntity.id, + mockClientUuid, + ); + + expect(result).toEqual({ affected: 1 }); + }); + + it('should throw internal exception on deleteClient', async () => { + cliTool.deleteToolClient.mockRejectedValue(new Error(mockENotFoundMessage)); + + try { + await service.deleteClient( + mockStandaloneDatabaseEntity.id, + mockClientUuid, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + }); + + describe('sendCommand', () => { + it('should successfully execute command and return text response', async () => { + const dto: SendCommandDto = { command: mockMemoryUsageCommand }; + const formatSpy = jest.spyOn(textFormatter, 'format'); + const mockResult: SendCommandResponse = { + response: mockIntegerResponse, + status: CommandExecutionStatus.Success, + }; + when(cliTool.execCommand) + .calledWith(mockClientOptions, 'memory', ['usage', 'key'], undefined) + .mockReturnValue(5); + + const result = await service.sendCommand(mockClientOptions, dto); + + expect(result).toEqual(mockResult); + expect(formatSpy).toHaveBeenCalled(); + }); + it('should successfully execute command and return raw response', async () => { + const dto: SendCommandDto = { + command: mockMemoryUsageCommand, + outputFormat: CliOutputFormatterTypes.Raw, + }; + const formatSpy = jest.spyOn(rawFormatter, 'format'); + const mockResult: SendCommandResponse = { + response: 5, + status: CommandExecutionStatus.Success, + }; + when(cliTool.execCommand) + .calledWith(mockClientOptions, 'memory', ['usage', 'key'], undefined) + .mockReturnValue(5); + + const result = await service.sendCommand(mockClientOptions, dto); + + expect(result).toEqual(mockResult); + expect(formatSpy).toHaveBeenCalled(); + }); + it('should return response with [CLI_COMMAND_NOT_SUPPORTED] error for sendCommand', async () => { + const command = CliToolUnsupportedCommands.ScriptDebug; + const dto: SendCommandDto = { command }; + const mockResult: SendCommandResponse = { + response: ERROR_MESSAGES.CLI_COMMAND_NOT_SUPPORTED( + command.toUpperCase(), + ), + status: CommandExecutionStatus.Fail, + }; + + const result = await service.sendCommand(mockClientOptions, dto); + + expect(result).toEqual(mockResult); + }); + + it('should return response with [CLI_UNTERMINATED_QUOTES] error for sendCommand', async () => { + const command = mockGetEscapedKeyCommand; + const dto: SendCommandDto = { command }; + const mockResult: SendCommandResponse = { + response: ERROR_MESSAGES.CLI_UNTERMINATED_QUOTES(), + status: CommandExecutionStatus.Fail, + }; + + const result = await service.sendCommand(mockClientOptions, dto); + + expect(result).toEqual(mockResult); + }); + + it('should return response with redis reply error', async () => { + const replyError: ReplyError = { + ...mockRedisWrongTypeError, + name: 'ReplyError', + command: 'GET', + }; + cliTool.execCommand.mockRejectedValue(replyError); + const dto: SendCommandDto = { command: 'get hashKey' }; + const mockResult: SendCommandResponse = { + response: replyError.message, + status: CommandExecutionStatus.Fail, + }; + + const result = await service.sendCommand(mockClientOptions, dto); + + expect(result).toEqual(mockResult); + }); + + it('should throw internal exception for sendCommand', async () => { + const dto: SendCommandDto = { command: 'get key' }; + cliTool.execCommand.mockRejectedValue(new Error(mockENotFoundMessage)); + + try { + await service.sendCommand(mockClientOptions, dto); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + + it('Should proxy EncryptionService errors for sendCommand', async () => { + const dto: SendCommandDto = { command: 'get key' }; + cliTool.execCommand.mockRejectedValue(new KeytarUnavailableException()); + + try { + await service.sendCommand(mockClientOptions, dto); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(KeytarUnavailableException); + } + }); + it('should return response in correct format for human-readable commands for sendCommand', async () => { + const dto: SendCommandDto = { command: mockServerInfoCommand }; + const mockResult: SendCommandResponse = { + response: mockRedisServerInfoResponse, + status: CommandExecutionStatus.Success, + }; + when(cliTool.execCommand) + .calledWith(mockClientOptions, 'info', ['server'], 'utf8') + .mockReturnValue(mockRedisServerInfoResponse); + + const result = await service.sendCommand(mockClientOptions, dto); + + expect(result).toEqual(mockResult); + }); + }); + + describe('sendClusterCommand', () => { + beforeEach(async () => { + service.sendCommandForSingleNode = jest.fn(); + service.sendCommandForNodes = jest.fn(); + }); + it('should call sendCommandForNodes method', async () => { + const dto: SendClusterCommandDto = { + command: mockMemoryUsageCommand, + role: ClusterNodeRole.Master, + }; + + await service.sendClusterCommand(mockClientOptions, dto); + + expect(service.sendCommandForNodes).toHaveBeenCalled(); + }); + it('should call sendCommandForSingleNode method', async () => { + const dto: SendClusterCommandDto = { + command: mockMemoryUsageCommand, + role: ClusterNodeRole.All, + nodeOptions: { ...mockNode, enableRedirection: true }, + }; + + await service.sendClusterCommand(mockClientOptions, dto); + + expect(service.sendCommandForSingleNode).toHaveBeenCalled(); + }); + + it('Should proxy EncryptionService errors for sendClusterCommand', async () => { + const dto: SendClusterCommandDto = { + command: mockMemoryUsageCommand, + role: ClusterNodeRole.All, + nodeOptions: { ...mockNode, enableRedirection: true }, + }; + service.sendCommandForSingleNode = jest.fn().mockRejectedValue(new KeytarUnavailableException()); + + await expect(service.sendClusterCommand(mockClientOptions, dto)).rejects.toThrow(KeytarUnavailableException); + }); + }); + + describe('sendCommandForNodes', () => { + it('should successfully execute command for masters', async () => { + const command = mockMemoryUsageCommand; + const mockResult: SendClusterCommandResponse[] = [ + { + response: mockIntegerResponse, + node: mockNode, + status: CommandExecutionStatus.Success, + }, + ]; + cliTool.execCommandForNodes.mockResolvedValue([ + { response: 5, ...mockNode, status: CommandExecutionStatus.Success }, + ]); + + const result = await service.sendCommandForNodes( + mockClientOptions, + command, + ClusterNodeRole.Master, + ); + + expect(result).toEqual(mockResult); + }); + + it('should return response in correct format for human-readable commands for sendCommandForNodes', async () => { + const mockResult: SendClusterCommandResponse[] = [ + { + response: mockRedisServerInfoResponse, + node: mockNode, + status: CommandExecutionStatus.Success, + }, + ]; + cliTool.execCommandForNodes.mockResolvedValue([ + { + response: mockRedisServerInfoResponse, + ...mockNode, + status: CommandExecutionStatus.Success, + }, + ]); + + const result = await service.sendCommandForNodes( + mockClientOptions, + mockServerInfoCommand, + ClusterNodeRole.Master, + ); + + expect(result).toEqual(mockResult); + expect(cliTool.execCommandForNodes).toHaveBeenCalledWith( + mockClientOptions, + 'info', + ['server'], + ClusterNodeRole.Master, + 'utf8', + ); + }); + + it('should return response with [CLI_COMMAND_NOT_SUPPORTED] error for sendCommandForNodes', async () => { + const command = CliToolUnsupportedCommands.ScriptDebug; + const mockResult: SendClusterCommandResponse[] = [ + { + response: ERROR_MESSAGES.CLI_COMMAND_NOT_SUPPORTED( + command.toUpperCase(), + ), + status: CommandExecutionStatus.Fail, + }, + ]; + + const result = await service.sendCommandForNodes( + mockClientOptions, + command, + ClusterNodeRole.Master, + ); + + expect(result).toEqual(mockResult); + }); + + it('should return response with [CLI_UNTERMINATED_QUOTES] error for sendCommandForNodes', async () => { + const command = mockGetEscapedKeyCommand; + const mockResult: SendClusterCommandResponse[] = [ + { + response: ERROR_MESSAGES.CLI_UNTERMINATED_QUOTES(), + status: CommandExecutionStatus.Fail, + }, + ]; + + const result = await service.sendCommandForNodes( + mockClientOptions, + command, + ClusterNodeRole.Master, + ); + + expect(result).toEqual(mockResult); + }); + it('should throw [WrongDatabaseTypeError]', async () => { + const command = mockMemoryUsageCommand; + cliTool.execCommandForNodes.mockRejectedValue( + new WrongDatabaseTypeError(ERROR_MESSAGES.WRONG_DATABASE_TYPE), + ); + + try { + await service.sendCommandForNodes( + mockClientOptions, + command, + ClusterNodeRole.Master, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual(ERROR_MESSAGES.WRONG_DATABASE_TYPE); + } + }); + it('should throw internal exception', async () => { + const command = mockMemoryUsageCommand; + cliTool.execCommandForNodes.mockRejectedValue(new Error(mockENotFoundMessage)); + + try { + await service.sendCommandForNodes( + mockClientOptions, + command, + ClusterNodeRole.Master, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + it('Should proxy EncryptionService errors', async () => { + const command = mockMemoryUsageCommand; + cliTool.execCommandForNodes.mockRejectedValue(new KeytarUnavailableException()); + + try { + await service.sendCommandForNodes( + mockClientOptions, + command, + ClusterNodeRole.Master, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(KeytarUnavailableException); + } + }); + }); + + describe('sendCommandForSingleNode', () => { + const nodeOptions = { ...mockNode, enableRedirection: true }; + it('should successfully execute command for single', async () => { + const command = mockMemoryUsageCommand; + const mockResult: SendClusterCommandResponse = { + response: mockIntegerResponse, + node: mockNode, + status: CommandExecutionStatus.Success, + }; + cliTool.execCommandForNode.mockResolvedValue({ + response: 5, + ...mockNode, + status: CommandExecutionStatus.Success, + }); + + const result = await service.sendCommandForSingleNode( + mockClientOptions, + command, + ClusterNodeRole.All, + nodeOptions, + ); + expect(result).toEqual(mockResult); + }); + + it('should return human-readable commands for sendCommandForSingleNode', async () => { + const mockResult: SendClusterCommandResponse = { + response: mockRedisServerInfoResponse, + node: mockNode, + status: CommandExecutionStatus.Success, + }; + cliTool.execCommandForNode.mockResolvedValue({ + response: mockRedisServerInfoResponse, + ...mockNode, + status: CommandExecutionStatus.Success, + }); + + const result = await service.sendCommandForSingleNode( + mockClientOptions, + mockServerInfoCommand, + ClusterNodeRole.All, + nodeOptions, + ); + expect(result).toEqual(mockResult); + expect(cliTool.execCommandForNode).toHaveBeenCalledWith( + mockClientOptions, + 'info', + ['server'], + ClusterNodeRole.All, + `${mockNode.host}:${mockNode.port}`, + 'utf8', + ); + }); + + it('should successfully execute command for single node with redirection', async () => { + const command = 'set foo bar'; + const mockResult: SendClusterCommandResponse = { + response: '-> Redirected to slot [7008] located at 127.0.0.1:7002\nOK', + node: { ...mockNode, port: 7002 }, + status: CommandExecutionStatus.Success, + }; + cliTool.execCommandForNode + .mockResolvedValueOnce({ + response: mockRedisMovedError.message, + error: mockRedisMovedError, + status: CommandExecutionStatus.Fail, + }) + .mockResolvedValueOnce({ + response: 'OK', + host: '127.0.0.1', + port: 7002, + status: CommandExecutionStatus.Success, + }); + + const result = await service.sendCommandForSingleNode( + mockClientOptions, + command, + ClusterNodeRole.All, + nodeOptions, + ); + + expect(cliTool.execCommandForNode).toHaveBeenCalledTimes(2); + expect(result).toEqual(mockResult); + }); + it('should return response for single node with redirection error', async () => { + const command = 'set foo bar'; + const mockResult: SendClusterCommandResponse = { + response: mockRedisMovedError.message, + node: mockNode, + status: CommandExecutionStatus.Fail, + }; + cliTool.execCommandForNode.mockResolvedValueOnce({ + response: mockRedisMovedError.message, + error: mockRedisMovedError, + ...mockNode, + status: CommandExecutionStatus.Fail, + }); + + const result = await service.sendCommandForSingleNode( + mockClientOptions, + command, + ClusterNodeRole.All, + { ...nodeOptions, enableRedirection: false }, + ); + + expect(cliTool.execCommandForNode).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockResult); + }); + it('should return response with [CLI_COMMAND_NOT_SUPPORTED] error for sendCommandForSingleNode', async () => { + const command = CliToolUnsupportedCommands.ScriptDebug; + const mockResult: SendClusterCommandResponse = { + response: ERROR_MESSAGES.CLI_COMMAND_NOT_SUPPORTED( + command.toUpperCase(), + ), + status: CommandExecutionStatus.Fail, + }; + + const result = await service.sendCommandForSingleNode( + mockClientOptions, + command, + ClusterNodeRole.All, + nodeOptions, + ); + + expect(result).toEqual(mockResult); + }); + it('should return response with [CLI_UNTERMINATED_QUOTES] error for sendCommandForSingleNode', async () => { + const command = mockGetEscapedKeyCommand; + const mockResult: SendClusterCommandResponse = { + response: ERROR_MESSAGES.CLI_UNTERMINATED_QUOTES(), + status: CommandExecutionStatus.Fail, + }; + + const result = await service.sendCommandForSingleNode( + mockClientOptions, + command, + ClusterNodeRole.All, + nodeOptions, + ); + + expect(result).toEqual(mockResult); + }); + it('should throw [WrongDatabaseTypeError]', async () => { + const command = 'get key'; + cliTool.execCommandForNode.mockRejectedValue( + new WrongDatabaseTypeError(ERROR_MESSAGES.WRONG_DATABASE_TYPE), + ); + + try { + await service.sendCommandForSingleNode( + mockClientOptions, + command, + ClusterNodeRole.All, + nodeOptions, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual(ERROR_MESSAGES.WRONG_DATABASE_TYPE); + } + }); + it('should throw [ClusterNodeNotFoundError]', async () => { + const command = 'get key'; + cliTool.execCommandForNode.mockRejectedValue( + new ClusterNodeNotFoundError( + ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND('127.0.0.1:7002'), + ), + ); + + try { + await service.sendCommandForSingleNode( + mockClientOptions, + command, + ClusterNodeRole.All, + nodeOptions, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + } + }); + it('should throw internal exception', async () => { + const command = 'get key'; + cliTool.execCommandForNodes.mockRejectedValue(new Error(mockENotFoundMessage)); + + try { + await service.sendCommandForSingleNode( + mockClientOptions, + command, + ClusterNodeRole.All, + nodeOptions, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + it('Should proxy EncryptionService errors', async () => { + const command = 'get key'; + cliTool.execCommandForNode.mockRejectedValue(new KeytarUnavailableException()); + + try { + await service.sendCommandForSingleNode( + mockClientOptions, + command, + ClusterNodeRole.All, + nodeOptions, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(KeytarUnavailableException); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.ts b/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.ts new file mode 100644 index 0000000000..9eb1370c5e --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.ts @@ -0,0 +1,345 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { CliToolService } from 'src/modules/cli/services/cli-tool/cli-tool.service'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { + ClusterNodeRole, + ClusterSingleNodeOptions, + CommandExecutionStatus, + CreateCliClientResponse, + DeleteClientResponse, + SendClusterCommandDto, + SendClusterCommandResponse, + SendCommandDto, + SendCommandResponse, +} from 'src/modules/cli/dto/cli.dto'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + checkHumanReadableCommands, + checkRedirectionError, + getUnsupportedCommands, + parseRedirectionError, + splitCliCommandLine, +} from 'src/utils/cli-helper'; +import { + CliCommandNotSupportedError, + CliParsingError, + ClusterNodeNotFoundError, + WrongDatabaseTypeError, +} from 'src/modules/cli/constants/errors'; +import { CliAnalyticsService } from 'src/modules/cli/services/cli-analytics/cli-analytics.service'; +import { EncryptionServiceErrorException } from 'src/modules/core/encryption/exceptions'; +import { AppTool } from 'src/models'; +import { OutputFormatterManager } from './output-formatter/output-formatter-manager'; +import { CliOutputFormatterTypes } from './output-formatter/output-formatter.interface'; +import { TextFormatterStrategy } from './output-formatter/strategies/text-formatter.strategy'; +import { RawFormatterStrategy } from './output-formatter/strategies/raw-formatter.strategy'; + +@Injectable() +export class CliBusinessService { + private logger = new Logger('CliService'); + + private outputFormatterManager: OutputFormatterManager; + + constructor( + private cliTool: CliToolService, + private cliAnalyticsService: CliAnalyticsService, + ) { + this.outputFormatterManager = new OutputFormatterManager(); + this.outputFormatterManager.addStrategy( + CliOutputFormatterTypes.Text, + new TextFormatterStrategy(), + ); + this.outputFormatterManager.addStrategy( + CliOutputFormatterTypes.Raw, + new RawFormatterStrategy(), + ); + } + + /** + * Method to create new redis client and return uuid + * @param instanceId + * @param namespace + */ + public async getClient( + instanceId: string, + namespace: string = AppTool.CLI, + ): Promise { + this.logger.log('Create Redis client for CLI.'); + try { + const uuid = await this.cliTool.createNewToolClient(instanceId, namespace); + this.logger.log('Succeed to create Redis client for CLI.'); + this.cliAnalyticsService.sendCliClientCreatedEvent(instanceId); + return { uuid }; + } catch (error) { + this.logger.error('Failed to create redis client for CLI.', error); + this.cliAnalyticsService.sendCliClientCreationFailedEvent(instanceId, error); + throw error; + } + } + + /** + * Method to close exist client and create a new one + * @param instanceId + * @param uuid + */ + public async reCreateClient( + instanceId: string, + uuid: string, + ): Promise { + this.logger.log('re-create Redis client for CLI.'); + try { + const clientUuid = await this.cliTool.reCreateToolClient( + instanceId, + uuid, + ); + this.logger.log('Succeed to re-create Redis client for CLI.'); + this.cliAnalyticsService.sendCliClientRecreatedEvent(instanceId); + return { uuid: clientUuid }; + } catch (error) { + this.logger.error('Failed to re-create redis client for CLI.', error); + this.cliAnalyticsService.sendCliClientCreationFailedEvent(instanceId, error); + throw error; + } + } + + /** + * Method to close exist redis client + * @param instanceId + * @param uuid + */ + public async deleteClient( + instanceId: string, + uuid: string, + ): Promise { + this.logger.log('Deleting Redis client for CLI.'); + try { + const affected = await this.cliTool.deleteToolClient(instanceId, uuid); + this.logger.log('Succeed to delete Redis client for CLI.'); + this.cliAnalyticsService.sendCliClientDeletedEvent(affected, instanceId); + return { affected }; + } catch (error) { + this.logger.error('Failed to delete Redis client for CLI.', error); + throw new InternalServerErrorException(error.message); + } + } + + /** + * Method to execute cli command for redis client and return result + * @param clientOptions + * @param dto + */ + public async sendCommand( + clientOptions: IFindRedisClientInstanceByOptions, + dto: SendCommandDto, + ): Promise { + this.logger.log('Executing redis CLI command.'); + const { command: commandLine } = dto; + const outputFormat = dto.outputFormat || CliOutputFormatterTypes.Text; + try { + const formatter = this.outputFormatterManager.getStrategy(outputFormat); + const [command, ...args] = splitCliCommandLine(commandLine); + const replyEncoding = checkHumanReadableCommands(`${command} ${args[0]}`) ? 'utf8' : undefined; + this.checkUnsupportedCommands(`${command} ${args[0]}`); + + const reply = await this.cliTool.execCommand(clientOptions, command, args, replyEncoding); + + this.logger.log('Succeed to execute redis CLI command.'); + this.cliAnalyticsService.sendCliCommandExecutedEvent( + clientOptions.instanceId, + { + command, + outputFormat, + }, + ); + return { + response: formatter.format(reply), + status: CommandExecutionStatus.Success, + }; + } catch (error) { + this.logger.error('Failed to execute redis CLI command.', error); + + if ( + error instanceof CliParsingError + || error instanceof CliCommandNotSupportedError + || error?.name === 'ReplyError' + ) { + this.cliAnalyticsService.sendCliCommandErrorEvent(clientOptions.instanceId, error); + return { response: error.message, status: CommandExecutionStatus.Fail }; + } + this.cliAnalyticsService.sendCliConnectionErrorEvent(clientOptions.instanceId, error); + + if (error instanceof EncryptionServiceErrorException) { + throw error; + } + + throw new InternalServerErrorException(error.message); + } + } + + /** + * Method to execute cli command for redis.cluster client and return result + * @param clientOptions + * @param dto + */ + public async sendClusterCommand( + clientOptions: IFindRedisClientInstanceByOptions, + dto: SendClusterCommandDto, + ): Promise { + this.logger.log('Executing redis.cluster CLI command.'); + const { + command, role, nodeOptions, outputFormat, + } = dto; + if (nodeOptions) { + const result = await this.sendCommandForSingleNode( + clientOptions, + command, + role, + nodeOptions, + outputFormat, + ); + return [result]; + } + return this.sendCommandForNodes(clientOptions, command, role, outputFormat); + } + + public async sendCommandForNodes( + clientOptions: IFindRedisClientInstanceByOptions, + commandLine: string, + role: ClusterNodeRole, + outputFormat: CliOutputFormatterTypes = CliOutputFormatterTypes.Text, + ): Promise { + this.logger.log(`Executing redis.cluster CLI command for [${role}] nodes.`); + try { + const formatter = this.outputFormatterManager.getStrategy(outputFormat); + const [command, ...args] = splitCliCommandLine(commandLine); + const replyEncoding = checkHumanReadableCommands(`${command} ${args[0]}`) ? 'utf8' : undefined; + this.checkUnsupportedCommands(`${command} ${args[0]}`); + const result = await this.cliTool.execCommandForNodes( + clientOptions, + command, + args, + role, + replyEncoding, + ); + return result.map((nodeExecReply) => { + this.cliAnalyticsService.sendCliClusterCommandExecutedEvent( + clientOptions.instanceId, + nodeExecReply, + { command, outputFormat }, + ); + const { + response, status, host, port, + } = nodeExecReply; + return { + response: formatter.format(response), + status, + node: { host, port }, + }; + }); + } catch (error) { + this.logger.error('Failed to execute redis.cluster CLI command.', error); + + if (error instanceof CliParsingError || error instanceof CliCommandNotSupportedError) { + this.cliAnalyticsService.sendCliCommandErrorEvent(clientOptions.instanceId, error); + return [ + { response: error.message, status: CommandExecutionStatus.Fail }, + ]; + } + + this.cliAnalyticsService.sendCliConnectionErrorEvent(clientOptions.instanceId, error); + + if (error instanceof EncryptionServiceErrorException) { + throw error; + } + + if (error instanceof WrongDatabaseTypeError) { + throw new BadRequestException(error.message); + } + throw new InternalServerErrorException(error.message); + } + } + + public async sendCommandForSingleNode( + clientOptions: IFindRedisClientInstanceByOptions, + commandLine: string, + role: ClusterNodeRole, + nodeOptions: ClusterSingleNodeOptions, + outputFormat: CliOutputFormatterTypes = CliOutputFormatterTypes.Text, + ): Promise { + this.logger.log(`Executing redis.cluster CLI command for single node ${JSON.stringify(nodeOptions)}`); + try { + const formatter = this.outputFormatterManager.getStrategy(outputFormat); + const [command, ...args] = splitCliCommandLine(commandLine); + const replyEncoding = checkHumanReadableCommands(`${command} ${args[0]}`) ? 'utf8' : undefined; + this.checkUnsupportedCommands(`${command} ${args[0]}`); + const nodeAddress = `${nodeOptions.host}:${nodeOptions.port}`; + let result = await this.cliTool.execCommandForNode( + clientOptions, + command, + args, + role, + nodeAddress, + replyEncoding, + ); + if (result?.error && checkRedirectionError(result.error) && nodeOptions.enableRedirection) { + const { slot, address } = parseRedirectionError(result.error); + result = await this.cliTool.execCommandForNode( + clientOptions, + command, + args, + role, + address, + replyEncoding, + ); + result.response = formatter.format(result.response, { slot, address }); + } else { + result.response = formatter.format(result.response); + } + this.cliAnalyticsService.sendCliClusterCommandExecutedEvent( + clientOptions.instanceId, + result, + { command, outputFormat }, + ); + const { + host, port, error, ...rest + } = result; + return { ...rest, node: { host, port } }; + } catch (error) { + this.logger.error('Failed to execute redis.cluster CLI command.', error); + + if (error instanceof CliParsingError || error instanceof CliCommandNotSupportedError) { + this.cliAnalyticsService.sendCliCommandErrorEvent(clientOptions.instanceId, error); + return { response: error.message, status: CommandExecutionStatus.Fail }; + } + + this.cliAnalyticsService.sendCliConnectionErrorEvent(clientOptions.instanceId, error); + + if (error instanceof EncryptionServiceErrorException) { + throw error; + } + + if (error instanceof WrongDatabaseTypeError || error instanceof ClusterNodeNotFoundError) { + throw new BadRequestException(error.message); + } + throw new InternalServerErrorException(error.message); + } + } + + // eslint-disable-next-line class-methods-use-this + private checkUnsupportedCommands(commandLine: string) { + const unsupportedCommand = getUnsupportedCommands() + .find((command) => commandLine.toLowerCase().startsWith(command)); + if (unsupportedCommand) { + throw new CliCommandNotSupportedError( + ERROR_MESSAGES.CLI_COMMAND_NOT_SUPPORTED( + unsupportedCommand.toUpperCase(), + ), + ); + } + } +} diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter-manager.spec.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter-manager.spec.ts new file mode 100644 index 0000000000..6cda923cc3 --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter-manager.spec.ts @@ -0,0 +1,51 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TextFormatterStrategy } from './strategies/text-formatter.strategy'; +import { + CliOutputFormatterTypes, + IOutputFormatterStrategy, +} from './output-formatter.interface'; +import { OutputFormatterManager } from './output-formatter-manager'; + +class TestFormatterStrategy implements IOutputFormatterStrategy { + public format() { + return ''; + } +} +const strategyName = CliOutputFormatterTypes.Text; +const testStrategy = new TestFormatterStrategy(); + +describe('OutputFormatterManager', () => { + let outputFormatter: OutputFormatterManager; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [OutputFormatterManager], + }).compile(); + + outputFormatter = module.get( + OutputFormatterManager, + ); + }); + it('Should throw error if no strategy', () => { + try { + outputFormatter.getStrategy(strategyName); + } catch (e) { + expect(e.message).toEqual( + `Unsupported formatter strategy: ${strategyName}`, + ); + } + }); + it('Should add strategy to formatter and get it back', () => { + outputFormatter.addStrategy(strategyName, testStrategy); + expect(outputFormatter.getStrategy(strategyName)).toEqual(testStrategy); + }); + it('Should support TextFormatter strategy', () => { + outputFormatter.addStrategy( + CliOutputFormatterTypes.Text, + new TextFormatterStrategy(), + ); + expect( + outputFormatter.getStrategy(CliOutputFormatterTypes.Text), + ).toBeInstanceOf(TextFormatterStrategy); + }); +}); diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter-manager.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter-manager.ts new file mode 100644 index 0000000000..d2ce35e21a --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter-manager.ts @@ -0,0 +1,23 @@ +import { + CliOutputFormatterTypes, + IOutputFormatterStrategy, +} from './output-formatter.interface'; + +export class OutputFormatterManager { + private strategies = {}; + + addStrategy( + name: CliOutputFormatterTypes, + strategy: IOutputFormatterStrategy, + ): void { + this.strategies[name] = strategy; + } + + getStrategy(name: CliOutputFormatterTypes): IOutputFormatterStrategy { + if (!this.strategies[name]) { + throw new Error(`Unsupported formatter strategy: ${name}`); + } + + return this.strategies[name]; + } +} diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter.interface.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter.interface.ts new file mode 100644 index 0000000000..000b552e9e --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter.interface.ts @@ -0,0 +1,13 @@ +export enum CliOutputFormatterTypes { + Text = 'TEXT', + Raw = 'RAW', +} + +export interface IRedirectionInfo { + slot: string; + address: string; +} + +export interface IOutputFormatterStrategy { + format(reply: any, redirectedTo?: IRedirectionInfo): any; +} diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.spec.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.spec.ts new file mode 100644 index 0000000000..7a7e22bf24 --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.spec.ts @@ -0,0 +1,75 @@ +import { RawFormatterStrategy } from './raw-formatter.strategy'; + +describe('Cli RawFormatterStrategy', () => { + let strategy; + beforeEach(async () => { + strategy = new RawFormatterStrategy(); + }); + + describe('format', () => { + it('should return correct value for null', () => { + const input = null; + + const output = strategy.format(input); + + expect(output).toEqual(null); + }); + it('should return correct value for integer', () => { + const input = 1; + + const output = strategy.format(input); + + expect(output).toEqual(input); + }); + it('should return correct value for string', () => { + const input = Buffer.from('string value'); + + const output = strategy.format(input); + + expect(output).toEqual('string value'); + }); + it('should return correct value for empty array', () => { + const input = []; + + const output = strategy.format(input); + + expect(output).toEqual([]); + }); + it('should return correct value for nested array', () => { + const input = [ + Buffer.from('0'), + [ + Buffer.from('key'), + Buffer.from('"quoted""key"'), + Buffer.from('"quoted key"'), + ], + ]; + const mockResponse = ['0', ['key', '"quoted""key"', '"quoted key"']]; + const output = strategy.format(input); + + expect(output).toEqual(mockResponse); + }); + it('should return correct value for object', () => { + const input = { + field: Buffer.from('value'), + secondField: Buffer.from('value'), + }; + const mockResponse = { + field: 'value', + secondField: 'value', + }; + const output = strategy.format(input); + + expect(output).toEqual(mockResponse); + }); + it('should correctly return stringified json', () => { + const object = { + key: 'value', + }; + const input = Buffer.from(JSON.stringify(object)); + const output = strategy.format(input); + + expect(output).toEqual(JSON.stringify(object)); + }); + }); +}); diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.ts new file mode 100644 index 0000000000..5b3ca12bf1 --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.ts @@ -0,0 +1,43 @@ +import { isArray, isObject } from 'lodash'; +import { IOutputFormatterStrategy } from '../output-formatter.interface'; + +export class RawFormatterStrategy implements IOutputFormatterStrategy { + public format(reply: any): any { + if (reply instanceof Buffer) { + return this.formatRedisBufferReply(reply); + } + if (isArray(reply)) { + return this.formatRedisArrayReply(reply); + } + if (isObject(reply)) { + return this.formatRedisObjectReply(reply); + } + return reply; + } + + private formatRedisArrayReply(reply: Buffer | Buffer[]): any[] { + let result: any; + if (isArray(reply)) { + if (!reply.length) { + result = []; + } else { + result = reply.map((item) => this.formatRedisArrayReply(item)); + } + } else { + result = this.format(reply); + } + return result; + } + + private formatRedisBufferReply(reply: Buffer): string { + return reply.toString(); + } + + private formatRedisObjectReply(reply: Object): object { + const result = {}; + Object.keys(reply).forEach((key) => { + result[key] = this.format(reply[key]); + }); + return result; + } +} diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.spec.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.spec.ts new file mode 100644 index 0000000000..8dc0892290 --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.spec.ts @@ -0,0 +1,95 @@ +import { TextFormatterStrategy } from './text-formatter.strategy'; + +describe('Cli TextFormatterStrategy', () => { + let strategy; + beforeEach(async () => { + strategy = new TextFormatterStrategy(); + }); + + describe('format', () => { + it('should return correct value for null', () => { + const input = null; + + const output = strategy.format(input); + + expect(output).toEqual('(nil)'); + }); + it('should return correct value for integer', () => { + const input = 1; + + const output = strategy.format(input); + + expect(output).toEqual(`(integer) ${input}`); + }); + it('should return correct value for string', () => { + const input = Buffer.from('string value'); + + const output = strategy.format(input); + + expect(output).toEqual(`"${input}"`); + }); + it('should return correct value for empty array', () => { + const input = []; + + const output = strategy.format(input); + + expect(output).toEqual('(empty list or set)'); + }); + it('should return correct value for nested array', () => { + const input = [ + Buffer.from('0'), + [ + Buffer.from('key'), + Buffer.from('"quoted""key"'), + Buffer.from('"quoted key"'), + ], + ]; + const mockResponse = '1) "0"\n2) 1) "key"\n 2) "\\"quoted\\"\\"key\\""\n 3) "\\"quoted key\\""'; + const output = strategy.format(input); + + expect(output).toEqual(mockResponse); + }); + it('should return correct value for object', () => { + const input = { + field: Buffer.from('value'), + secondField: Buffer.from('value'), + }; + const mockResponse = '1) "field"\n2) "value"\n3) "secondField"\n4) "value"'; + const output = strategy.format(input); + + expect(output).toEqual(mockResponse); + }); + it('should correctly handle special characters', () => { + const input = Buffer.from('\u0007\b\t\n\r\\'); + const output = strategy.format(input); + + expect(output).toEqual('"\\a\\b\\t\\n\\r\\\\"'); + }); + it('should correctly handle hexadecimal', () => { + const input = Buffer.from('aced000573720008456d706c6f796565', 'hex'); + const output = strategy.format(input); + + expect(output).toEqual('"\\xac\\xed\\x00\\x05sr\\x00\\bEmployee"'); + }); + it('should correctly stringified json', () => { + const object = { + key: 'value', + }; + const input = Buffer.from(JSON.stringify(object)); + const output = strategy.format(input); + + expect(output).toEqual('"{\\"key\\":\\"value\\"}"'); + }); + it('should return correct value with redirection', () => { + const input = Buffer.from('string value'); + const mockOutput = `-> Redirected to slot [2222] located at 127.0.0.1:7000\n"${input.toString()}"`; + + const output = strategy.format(input, { + slot: '2222', + address: '127.0.0.1:7000', + }); + + expect(output).toEqual(mockOutput); + }); + }); +}); diff --git a/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.ts b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.ts new file mode 100644 index 0000000000..d7165d6271 --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.ts @@ -0,0 +1,97 @@ +import { + flattenDeep, isArray, isInteger, isNull, isObject, +} from 'lodash'; +import { IS_NON_PRINTABLE_ASCII_CHARACTER } from 'src/constants'; +import { decimalToHexString } from 'src/utils/cli-helper'; +import { + IOutputFormatterStrategy, + IRedirectionInfo, +} from '../output-formatter.interface'; + +export class TextFormatterStrategy implements IOutputFormatterStrategy { + public format(reply: any, redirectedTo: IRedirectionInfo): string { + let result; + if (isNull(reply)) { + result = '(nil)'; + } else if (isInteger(reply)) { + result = `(integer) ${reply}`; + } else if (reply instanceof Buffer) { + result = this.formatRedisBufferReply(reply); + } else if (isArray(reply)) { + result = this.formatRedisArrayReply(reply); + } else if (isObject(reply)) { + result = this.formatRedisArrayReply(flattenDeep(Object.entries(reply))); + } else { + result = reply; + } + if (redirectedTo) { + const { slot, address } = redirectedTo; + result = `-> Redirected to slot [${slot}] located at ${address}\n${result}`; + } + return result; + } + + private formatRedisArrayReply(reply: Buffer | Buffer[], level = 0): string { + let result: string; + if (isArray(reply)) { + if (!reply.length) { + result = '(empty list or set)'; + } else { + result = reply + .map((item, index) => { + const leftMargin = index > 0 ? ' '.repeat(level) : ''; + const lineIndex = `${leftMargin}${index + 1})`; + const value = this.formatRedisArrayReply(item, level + 1); + return `${lineIndex} ${value}`; + }) + .join('\n'); + } + } else { + result = reply instanceof Buffer + ? this.formatRedisBufferReply(reply) + : JSON.stringify(reply); + } + return result; + } + + private formatRedisBufferReply(reply: Buffer): string { + // Produces an escaped string representation of a byte string. + // Ported from sdscatrepr() function in sds.c from Redis source code. + // This is the function redis-cli uses to escape strings for output. + let result = '"'; + reply.forEach((byte: number) => { + const char = Buffer.from([byte]).toString(); + if (IS_NON_PRINTABLE_ASCII_CHARACTER.test(char)) { + result += `\\x${decimalToHexString(byte)}`; + } else { + switch (char) { + case '\\': + result += `\\${char}`; + break; + case '\u0007': // Bell character + result += '\\a'; + break; + case '"': + result += `\\${char}`; + break; + case '\b': + result += '\\b'; + break; + case '\t': + result += '\\t'; + break; + case '\n': + result += '\\n'; + break; + case '\r': + result += '\\r'; + break; + default: + result += char; + } + } + }); + result += '"'; + return result; + } +} diff --git a/redisinsight/api/src/modules/cli/services/cli-tool/cli-tool.service.spec.ts b/redisinsight/api/src/modules/cli/services/cli-tool/cli-tool.service.spec.ts new file mode 100644 index 0000000000..9c360183fa --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-tool/cli-tool.service.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import * as Redis from 'ioredis-mock'; +import { mockStandaloneDatabaseEntity } from 'src/__mocks__'; +import { + IFindRedisClientInstanceByOptions, + RedisService, +} from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { BrowserToolKeysCommands } from 'src/modules/browser/constants/browser-tool-commands'; +import { InternalServerErrorException } from '@nestjs/common'; +import { CliToolService } from 'src/modules/cli/services/cli-tool/cli-tool.service'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockClient = new Redis(); + +describe('CliToolService', () => { + let service: CliToolService; + let getRedisClient; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CliToolService, + { + provide: RedisService, + useFactory: () => ({}), + }, + { + provide: InstancesBusinessService, + useFactory: () => ({}), + }, + ], + }).compile(); + + service = await module.get(CliToolService); + getRedisClient = jest.spyOn(service, 'getRedisClient'); + mockClient.sendCommand = jest.fn(); + }); + + describe('execCommand', () => { + const keyName = 'keyName'; + it('should call sendCommand with correct args', async () => { + getRedisClient.mockResolvedValue(mockClient); + + await service.execCommand( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + ); + + expect(mockClient.sendCommand).toHaveBeenCalledWith( + expect.objectContaining({ name: 'memory', args: ['usage', keyName] }), + ); + }); + it('should throw error', async () => { + const error = new InternalServerErrorException( + ' Could not connect to localhost, please check the connection details.', + ); + getRedisClient.mockRejectedValue(error); + + await expect( + service.execCommand( + mockClientOptions, + BrowserToolKeysCommands.MemoryUsage, + [keyName], + ), + ).rejects.toThrow(InternalServerErrorException); + expect(mockClient.sendCommand).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/cli/services/cli-tool/cli-tool.service.ts b/redisinsight/api/src/modules/cli/services/cli-tool/cli-tool.service.ts new file mode 100644 index 0000000000..6fb98ade4f --- /dev/null +++ b/redisinsight/api/src/modules/cli/services/cli-tool/cli-tool.service.ts @@ -0,0 +1,202 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import IORedis from 'ioredis'; +import { v4 as uuidv4 } from 'uuid'; +import { AppTool, ReplyError } from 'src/models'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + IFindRedisClientInstanceByOptions, + RedisService, +} from 'src/modules/core/services/redis/redis.service'; +import { RedisConsumerAbstractService } from 'src/modules/shared/services/base/redis-consumer.abstract.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { + ClusterNodeNotFoundError, + WrongDatabaseTypeError, +} from 'src/modules/cli/constants/errors'; +import { + ClusterNodeRole, + CommandExecutionStatus, +} from 'src/modules/cli/dto/cli.dto'; +import { getConnectionName } from 'src/utils/redis-connection-helper'; + +export interface ICliExecResultFromNode { + host: string; + port: number; + response: any; + status: CommandExecutionStatus; + error?: any, +} + +@Injectable() +export class CliToolService extends RedisConsumerAbstractService { + private logger = new Logger('CliToolService'); + + constructor( + protected redisService: RedisService, + protected instancesBusinessService: InstancesBusinessService, + ) { + super(AppTool.CLI, redisService, instancesBusinessService); + } + + async execCommand( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommand: string, + args: Array, + replyEncoding?: string, + ): Promise { + const client = await this.getRedisClient(clientOptions); + this.logger.log(`Execute command '${toolCommand}', connectionName: ${getConnectionName(client)}`); + const [command, ...commandArgs] = toolCommand.split(' '); + return client.sendCommand( + new Redis.Command(command, [...commandArgs, ...args], { + replyEncoding, + }), + ); + } + + async execCommandForNodes( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommand: string, + args: Array, + nodeRole: ClusterNodeRole, + replyEncoding?: string, + ): Promise { + const [command, ...commandArgs] = toolCommand.split(' '); + const nodes: IORedis.Redis[] = await this.getClusterNodes( + clientOptions, + nodeRole, + ); + return await Promise.all( + nodes.map( + async (node: any): Promise => { + const { host, port } = node.options; + this.logger.log(`Execute command '${toolCommand}', connectionName: ${getConnectionName(node)}`); + try { + const response = await node.sendCommand( + new Redis.Command(command, [...commandArgs, ...args], { + replyEncoding, + }), + ); + return { + host, + port, + response, + status: CommandExecutionStatus.Success, + }; + } catch (error) { + return { + host, + port, + error, + response: error.message, + status: CommandExecutionStatus.Fail, + }; + } + }, + ), + ); + } + + async execCommandForNode( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommand: string, + args: Array, + nodeRole: ClusterNodeRole, + nodeAddress: string, + replyEncoding?: string, + ): Promise { + const [command, ...commandArgs] = toolCommand.split(' '); + const nodes: IORedis.Redis[] = await this.getClusterNodes( + clientOptions, + nodeRole, + ); + let node: any = nodes.find((item: IORedis.Redis) => { + const { host, port } = item.options; + return `${host}:${port}` === nodeAddress; + }); + if (!node) { + node = nodeRole === ClusterNodeRole.All + ? nodeAddress + : `${nodeAddress} [${nodeRole.toLowerCase()}]`; + throw new ClusterNodeNotFoundError( + ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND(node), + ); + } + const { host, port } = node.options; + this.logger.log(`Execute command '${toolCommand}', connectionName: ${getConnectionName(node)}`); + try { + const response = await node.sendCommand( + new Redis.Command(command, [...commandArgs, ...args], { + replyEncoding, + }), + ); + return { + response, + host, + port, + status: CommandExecutionStatus.Success, + }; + } catch (error) { + return { + response: error.message, + host, + port, + error, + status: CommandExecutionStatus.Fail, + }; + } + } + + async execPipeline(): Promise<[ReplyError | null, any]> { + throw new Error('CLI ERROR: Pipeline not supported'); + } + + async createNewToolClient(instanceId: string, namespace: string): Promise { + const uuid = uuidv4(); + await this.createNewClient(instanceId, uuid, namespace); + + return uuid; + } + + async reCreateToolClient(instanceId: string, uuid: string): Promise { + this.redisService.removeClientInstance({ + instanceId, + uuid, + tool: this.consumer, + }); + await this.createNewClient(instanceId, uuid); + + return uuid; + } + + async deleteToolClient(instanceId: string, uuid: string): Promise { + return this.redisService.removeClientInstance({ + instanceId, + uuid, + tool: this.consumer, + }); + } + + private async getClusterNodes( + clientOptions: IFindRedisClientInstanceByOptions, + role: ClusterNodeRole, + ): Promise { + const client = await this.getRedisClient(clientOptions); + if (!(client instanceof IORedis.Cluster)) { + throw new WrongDatabaseTypeError(ERROR_MESSAGES.WRONG_DATABASE_TYPE); + } + let nodes: IORedis.Redis[]; + switch (role) { + case ClusterNodeRole.Master: + nodes = client.nodes('master'); + break; + case ClusterNodeRole.Slave: + nodes = client.nodes('slave'); + break; + default: + nodes = client.nodes('all'); + } + return nodes; + } +} diff --git a/redisinsight/api/src/modules/commands/commands-json.provider.spec.ts b/redisinsight/api/src/modules/commands/commands-json.provider.spec.ts new file mode 100644 index 0000000000..c861fe54a5 --- /dev/null +++ b/redisinsight/api/src/modules/commands/commands-json.provider.spec.ts @@ -0,0 +1,85 @@ +import axios from 'axios'; +import * as fs from 'fs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockMainCommands, + mockRedijsonCommands, +} from 'src/__mocks__'; +import { CommandsJsonProvider } from 'src/modules/commands/commands-json.provider'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +jest.mock('fs'); +const mockedFs = fs as jest.Mocked; + +describe('CommandsJsonProvider', () => { + let service: CommandsJsonProvider; + let updateLatestJsonSpy; + + beforeEach(async () => { + jest.mock('fs', () => mockedFs); + + mockedFs.existsSync.mockReturnValue(true); + mockedFs.mkdirSync.mockReturnValue(''); + mockedFs.writeFileSync.mockReturnValue(undefined); + mockedAxios.get.mockResolvedValue({ data: JSON.stringify(mockMainCommands) }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: 'service', + useFactory: () => new CommandsJsonProvider('name', 'someurl', mockMainCommands), + }, + ], + }).compile(); + + service = module.get('service'); + updateLatestJsonSpy = jest.spyOn(service, 'updateLatestJson'); + }); + + describe('onModuleInit', () => { + it('should trigger updateLatestJson function', async () => { + await service.onModuleInit(); + + expect(updateLatestJsonSpy).toHaveBeenCalled(); + }); + }); + + describe('updateLatestJson', () => { + it('Should create dir and save proper json', async () => { + mockedFs.existsSync.mockReturnValueOnce(false); + + await service.onModuleInit(); + + // todo: uncomment after enable esModuleInterop in the tsconfig + // expect(mockedFs.mkdirSync).toHaveBeenCalled(); + // expect(mockedFs.writeFileSync).toHaveBeenCalled(); + }); + it('should not fail when incorrect data retrieved', async () => { + mockedAxios.get.mockResolvedValueOnce('incorrect json'); + await service.onModuleInit(); + + // todo: uncomment after enable esModuleInterop in the tsconfig + // expect(mockedFs.writeFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('getCommands', () => { + it('should return default config when file was not found', async () => { + mockedFs.readFileSync.mockImplementationOnce(() => { throw new Error('No file'); }); + + expect(await service.getCommands()).toEqual(mockMainCommands); + }); + it('should return default config when incorrect json received from file', async () => { + mockedFs.readFileSync.mockReturnValue('incorrect json'); + + expect(await service.getCommands()).toEqual(mockMainCommands); + }); + it('should return latest commands', async () => { + mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockRedijsonCommands)); + + expect(await service.getCommands()).toEqual(mockRedijsonCommands); + }); + }); +}); diff --git a/redisinsight/api/src/modules/commands/commands-json.provider.ts b/redisinsight/api/src/modules/commands/commands-json.provider.ts new file mode 100644 index 0000000000..cc5381c892 --- /dev/null +++ b/redisinsight/api/src/modules/commands/commands-json.provider.ts @@ -0,0 +1,75 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import axios from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; +import config from 'src/utils/config'; + +const PATH_CONFIG = config.get('dir_path'); + +@Injectable() +export class CommandsJsonProvider implements OnModuleInit { + private readonly logger: Logger; + + private readonly name: string; + + private readonly url: string; + + private readonly defaultCommands: Record; + + constructor(name, url, defaultCommands) { + this.name = name; + this.url = url; + this.defaultCommands = defaultCommands; + this.logger = new Logger(this.name); + } + + /** + * Updates latest json on startup + */ + async onModuleInit() { + // async operation to not wait for it and not block user in case when no internet connection + this.updateLatestJson(); + } + + /** + * Get latest json from external resource and save it locally + * @private + */ + private async updateLatestJson() { + try { + this.logger.log(`Trying to update ${this.name} commands...`); + const { data } = await axios.get(this.url, { + responseType: 'text', + transformResponse: [(raw) => raw], + }); + + if (!fs.existsSync(PATH_CONFIG.commands)) { + fs.mkdirSync(PATH_CONFIG.commands); + } + + fs.writeFileSync( + path.join(PATH_CONFIG.commands, `${this.name}.json`), + JSON.stringify(JSON.parse(data)), // check that we received proper json object + ); + this.logger.log(`Successfully updated ${this.name} commands`); + } catch (error) { + this.logger.error(`Unable to update ${this.name} commands`, error); + } + } + + /** + * Try to return latest commands + * In case of any errors will return default one + */ + async getCommands() { + try { + return JSON.parse(fs.readFileSync( + path.join(PATH_CONFIG.commands, `${this.name}.json`), + 'utf8', + )); + } catch (error) { + this.logger.error(`Unable to get latest ${this.name} commands. Return default.`, error); + return this.defaultCommands; + } + } +} diff --git a/redisinsight/api/src/modules/commands/commands.controller.ts b/redisinsight/api/src/modules/commands/commands.controller.ts new file mode 100644 index 0000000000..6993676af8 --- /dev/null +++ b/redisinsight/api/src/modules/commands/commands.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from '@nestjs/common'; +import { CommandsService } from 'src/modules/commands/commands.service'; +import { ApiTags } from '@nestjs/swagger'; + +@ApiTags('Commands') +@Controller('commands') +export class CommandsController { + constructor( + private readonly commandsService: CommandsService, + ) {} + + @Get() + async getAll(): Promise> { + return this.commandsService.getAll(); + } +} diff --git a/redisinsight/api/src/modules/commands/commands.module.ts b/redisinsight/api/src/modules/commands/commands.module.ts new file mode 100644 index 0000000000..a2cd32cc37 --- /dev/null +++ b/redisinsight/api/src/modules/commands/commands.module.ts @@ -0,0 +1,69 @@ +import { Module } from '@nestjs/common'; +import { CommandsController } from 'src/modules/commands/commands.controller'; +import { CommandsService } from 'src/modules/commands/commands.service'; +import { CommandsJsonProvider } from 'src/modules/commands/commands-json.provider'; +import config from 'src/utils/config'; +import * as defaultMainCommands from 'src/constants/commands/main.json'; +import * as defaultRedisearchCommands from 'src/constants/commands/redisearch.json'; +import * as defaultRedijsonCommands from 'src/constants/commands/redijson.json'; +import * as defaultRedistimeseriesCommands from 'src/constants/commands/redistimeseries.json'; +import * as defaultRedisaiCommands from 'src/constants/commands/redisai.json'; +import * as defaultRedisgraphCommands from 'src/constants/commands/redisgraph.json'; + +const COMMANDS_CONFIG = config.get('commands'); + +@Module({ + controllers: [CommandsController], + providers: [ + CommandsService, + { + provide: 'mainCommandsProvider', + useFactory: () => new CommandsJsonProvider( + 'main', + COMMANDS_CONFIG.mainUrl, + defaultMainCommands, + ), + }, + { + provide: 'redisearchCommandsProvider', + useFactory: () => new CommandsJsonProvider( + 'redisearch', + COMMANDS_CONFIG.redisearchUrl, + defaultRedisearchCommands, + ), + }, + { + provide: 'redijsonCommandsProvider', + useFactory: () => new CommandsJsonProvider( + 'redijson', + COMMANDS_CONFIG.redijsonUrl, + defaultRedijsonCommands, + ), + }, + { + provide: 'redistimeseriesCommandsProvider', + useFactory: () => new CommandsJsonProvider( + 'redistimeseries', + COMMANDS_CONFIG.redistimeseriesUrl, + defaultRedistimeseriesCommands, + ), + }, + { + provide: 'redisaiCommandsProvider', + useFactory: () => new CommandsJsonProvider( + 'redisai', + COMMANDS_CONFIG.redisaiUrl, + defaultRedisaiCommands, + ), + }, + { + provide: 'redisgraphCommandsProvider', + useFactory: () => new CommandsJsonProvider( + 'redisgraph', + COMMANDS_CONFIG.redisgraphUrl, + defaultRedisgraphCommands, + ), + }, + ], +}) +export class CommandsModule {} diff --git a/redisinsight/api/src/modules/commands/commands.service.spec.ts b/redisinsight/api/src/modules/commands/commands.service.spec.ts new file mode 100644 index 0000000000..115c2b5ab9 --- /dev/null +++ b/redisinsight/api/src/modules/commands/commands.service.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CommandsService } from 'src/modules/commands/commands.service'; +import { + mockCommandsJsonProvider, + mockMainCommands, + mockRedijsonCommands, + mockRedisaiCommands, + mockRedisearchCommands, + mockRedisgraphCommands, + mockRedistimeseriesCommands, + MockType, +} from 'src/__mocks__'; +import { CommandsJsonProvider } from 'src/modules/commands/commands-json.provider'; + +describe('CommandsService', () => { + let service: CommandsService; + let mainCommandsProvider: MockType; + let redisearchCommandsProvider: MockType; + let redijsonCommandsProvider: MockType; + let redistimeseriesCommandsProvider: MockType; + let redisaiCommandsProvider: MockType; + let redisgraphCommandsProvider: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CommandsService, + { + provide: 'mainCommandsProvider', + useFactory: mockCommandsJsonProvider, + }, + { + provide: 'redisearchCommandsProvider', + useFactory: mockCommandsJsonProvider, + }, + { + provide: 'redijsonCommandsProvider', + useFactory: mockCommandsJsonProvider, + }, + { + provide: 'redistimeseriesCommandsProvider', + useFactory: mockCommandsJsonProvider, + }, + { + provide: 'redisaiCommandsProvider', + useFactory: mockCommandsJsonProvider, + }, + { + provide: 'redisgraphCommandsProvider', + useFactory: mockCommandsJsonProvider, + }, + ], + }).compile(); + + service = module.get(CommandsService); + mainCommandsProvider = module.get('mainCommandsProvider'); + redisearchCommandsProvider = module.get('redisearchCommandsProvider'); + redijsonCommandsProvider = module.get('redijsonCommandsProvider'); + redistimeseriesCommandsProvider = module.get('redistimeseriesCommandsProvider'); + redisaiCommandsProvider = module.get('redisaiCommandsProvider'); + redisgraphCommandsProvider = module.get('redisgraphCommandsProvider'); + + mainCommandsProvider.getCommands.mockResolvedValue(mockMainCommands); + redisearchCommandsProvider.getCommands.mockResolvedValue(mockRedisearchCommands); + redijsonCommandsProvider.getCommands.mockResolvedValue(mockRedijsonCommands); + redistimeseriesCommandsProvider.getCommands.mockResolvedValue(mockRedistimeseriesCommands); + redisaiCommandsProvider.getCommands.mockResolvedValue(mockRedisaiCommands); + redisgraphCommandsProvider.getCommands.mockResolvedValue(mockRedisgraphCommands); + }); + + describe('getAll', () => { + it('Should return merged commands into one', async () => { + expect(await service.getAll()).toEqual({ + ...mockRedisearchCommands, + ...mockRedijsonCommands, + ...mockRedistimeseriesCommands, + ...mockRedisaiCommands, + ...mockRedisgraphCommands, + ...mockMainCommands, + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/commands/commands.service.ts b/redisinsight/api/src/modules/commands/commands.service.ts new file mode 100644 index 0000000000..787b5281f8 --- /dev/null +++ b/redisinsight/api/src/modules/commands/commands.service.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { CommandsJsonProvider } from 'src/modules/commands/commands-json.provider'; + +@Injectable() +export class CommandsService { + constructor( + @Inject('redisearchCommandsProvider') + private redisearchCommandsProvider: CommandsJsonProvider, + @Inject('redijsonCommandsProvider') + private redijsonCommandsProvider: CommandsJsonProvider, + @Inject('redistimeseriesCommandsProvider') + private redistimeseriesCommandsProvider: CommandsJsonProvider, + @Inject('redisaiCommandsProvider') + private redisaiCommandsProvider: CommandsJsonProvider, + @Inject('redisgraphCommandsProvider') + private redisgraphCommandsProvider: CommandsJsonProvider, + @Inject('mainCommandsProvider') + private mainCommandsProvider: CommandsJsonProvider, + ) {} + + /** + * Get all commands merged into single object + */ + async getAll(): Promise> { + return { + ...(await this.redisearchCommandsProvider.getCommands()), + ...(await this.redijsonCommandsProvider.getCommands()), + ...(await this.redistimeseriesCommandsProvider.getCommands()), + ...(await this.redisaiCommandsProvider.getCommands()), + ...(await this.redisgraphCommandsProvider.getCommands()), + ...(await this.mainCommandsProvider.getCommands()), + }; + } +} diff --git a/redisinsight/api/src/modules/core/core.module.ts b/redisinsight/api/src/modules/core/core.module.ts new file mode 100644 index 0000000000..8205bd9885 --- /dev/null +++ b/redisinsight/api/src/modules/core/core.module.ts @@ -0,0 +1,65 @@ +import { DynamicModule, Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CaCertificateEntity } from 'src/modules/core/models/ca-certificate.entity'; +import { ClientCertificateEntity } from 'src/modules/core/models/client-certificate.entity'; +import { AgreementsRepository } from './repositories/agreements.repository'; +import { ServerRepository } from './repositories/server.repository'; +import { SettingsRepository } from './repositories/settings.repository'; +import settingsOnPremiseFactory from './providers/settings-on-premise'; +import serverOnPremiseFactory from './providers/server-on-premise'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; +import { KeytarEncryptionStrategy } from 'src/modules/core/encryption/strategies/keytar-encryption.strategy'; +import { PlainEncryptionStrategy } from 'src/modules/core/encryption/strategies/plain-encryption.strategy'; +import { CaCertBusinessService } from './services/certificates/ca-cert-business/ca-cert-business.service'; +import { ClientCertBusinessService } from './services/certificates/client-cert-business/client-cert-business.service'; +import { RedisService } from './services/redis/redis.service'; +import { AnalyticsService } from './services/analytics/analytics.service'; +import { SettingsAnalyticsService } from './services/settings-analytics/settings-analytics.service'; + +interface IModuleOptions { + buildType: string; +} + +/** + * Core module + */ +@Global() +@Module({}) +export class CoreModule { + static register(options: IModuleOptions): DynamicModule { + // TODO: use different module configurations depending on buildType + return { + module: CoreModule, + imports: [ + TypeOrmModule.forFeature([ + CaCertificateEntity, + ClientCertificateEntity, + AgreementsRepository, + ServerRepository, + SettingsRepository, + ]), + ], + providers: [ + settingsOnPremiseFactory, + serverOnPremiseFactory, + KeytarEncryptionStrategy, + PlainEncryptionStrategy, + EncryptionService, + AnalyticsService, + RedisService, + CaCertBusinessService, + ClientCertBusinessService, + SettingsAnalyticsService, + ], + exports: [ + settingsOnPremiseFactory, + serverOnPremiseFactory, + EncryptionService, + AnalyticsService, + RedisService, + CaCertBusinessService, + ClientCertBusinessService, + ], + }; + } +} diff --git a/redisinsight/api/src/modules/core/encryption/encryption.service.spec.ts b/redisinsight/api/src/modules/core/encryption/encryption.service.spec.ts new file mode 100644 index 0000000000..04282804a9 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/encryption.service.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockEncryptionStrategy, + mockEncryptResult, + mockSettingsProvider, + MockType, +} from 'src/__mocks__'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; +import { PlainEncryptionStrategy } from 'src/modules/core/encryption/strategies/plain-encryption.strategy'; +import { KeytarEncryptionStrategy } from 'src/modules/core/encryption/strategies/keytar-encryption.strategy'; +import { EncryptionStrategy } from 'src/modules/core/encryption/models'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { UnsupportedEncryptionStrategyException } from 'src/modules/core/encryption/exceptions'; + +describe('EncryptionService', () => { + let service: EncryptionService; + let plainEncryptionStrategy: MockType; + let keytarEncryptionStrategy: MockType; + let settingsProvider: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EncryptionService, + { + provide: PlainEncryptionStrategy, + useFactory: mockEncryptionStrategy, + }, + { + provide: KeytarEncryptionStrategy, + useFactory: mockEncryptionStrategy, + }, + { + provide: 'SETTINGS_PROVIDER', + useFactory: mockSettingsProvider, + }, + ], + }).compile(); + + service = module.get(EncryptionService); + plainEncryptionStrategy = module.get(PlainEncryptionStrategy); + keytarEncryptionStrategy = module.get(KeytarEncryptionStrategy); + settingsProvider = module.get('SETTINGS_PROVIDER'); + }); + + describe('getAvailableEncryptionStrategies', () => { + it('Should return list 2 strategies available', async () => { + keytarEncryptionStrategy.isAvailable.mockResolvedValueOnce(true); + + expect(await service.getAvailableEncryptionStrategies()).toEqual([ + EncryptionStrategy.PLAIN, + EncryptionStrategy.KEYTAR, + ]); + }); + it('Should return list with one strategy available', async () => { + keytarEncryptionStrategy.isAvailable.mockResolvedValueOnce(false); + + expect(await service.getAvailableEncryptionStrategies()).toEqual([ + EncryptionStrategy.PLAIN, + ]); + }); + }); + + describe('getEncryptionStrategy', () => { + it('Should return KEYTAR strategy based on app agreements', async () => { + settingsProvider.getSettings.mockResolvedValueOnce({ + agreements: { encryption: true }, + }); + + expect(await service.getEncryptionStrategy()).toEqual(keytarEncryptionStrategy); + }); + it('Should return PLAIN strategy based on app agreements', async () => { + settingsProvider.getSettings.mockResolvedValueOnce({ + agreements: { encryption: false }, + }); + + expect(await service.getEncryptionStrategy()).toEqual(plainEncryptionStrategy); + }); + it('Should throw an error if encryption strategy was not set by user', async () => { + settingsProvider.getSettings.mockResolvedValueOnce({ + agreements: { encryption: null }, + }); + + await expect(service.getEncryptionStrategy()).rejects.toThrow(UnsupportedEncryptionStrategyException); + }); + }); + + describe('encrypt', () => { + it('Should encrypt data and return proper response', async () => { + settingsProvider.getSettings.mockResolvedValueOnce({ + agreements: { encryption: true }, + }); + keytarEncryptionStrategy.encrypt.mockResolvedValueOnce(mockEncryptResult); + + expect(await service.encrypt('string')).toEqual(mockEncryptResult); + }); + }); + + describe('decrypt', () => { + it('Should return decrypted string', async () => { + settingsProvider.getSettings.mockResolvedValueOnce({ + agreements: { encryption: true }, + }); + keytarEncryptionStrategy.decrypt.mockResolvedValueOnce(mockEncryptResult.data); + + expect(await service.decrypt('string', EncryptionStrategy.KEYTAR)).toEqual(mockEncryptResult.data); + }); + it('Should return null when no data passed', async () => { + expect(await service.decrypt(null, EncryptionStrategy.KEYTAR)).toEqual(null); + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/encryption/encryption.service.ts b/redisinsight/api/src/modules/core/encryption/encryption.service.ts new file mode 100644 index 0000000000..0a63e1aad1 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/encryption.service.ts @@ -0,0 +1,79 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { KeytarEncryptionStrategy } from 'src/modules/core/encryption/strategies/keytar-encryption.strategy'; +import { PlainEncryptionStrategy } from 'src/modules/core/encryption/strategies/plain-encryption.strategy'; +import { EncryptionResult, EncryptionStrategy } from 'src/modules/core/encryption/models'; +import { IEncryptionStrategy } from 'src/modules/core/encryption/strategies/encryption-strategy.interface'; +import { + UnsupportedEncryptionStrategyException, +} from 'src/modules/core/encryption/exceptions'; + +@Injectable() +export class EncryptionService { + constructor( + @Inject('SETTINGS_PROVIDER') + private readonly settingsProvider: ISettingsProvider, + private readonly keytarEncryptionStrategy: KeytarEncryptionStrategy, + private readonly plainEncryptionStrategy: PlainEncryptionStrategy, + ) {} + + /** + * Returns list of available encryption strategies + * It is needed for users to choose one and save it in the app settings + */ + async getAvailableEncryptionStrategies(): Promise { + const strategies = [ + EncryptionStrategy.PLAIN, + ]; + + if (await this.keytarEncryptionStrategy.isAvailable()) { + strategies.push(EncryptionStrategy.KEYTAR); + } + + return strategies; + } + + /** + * Get encryption strategy based on app settings + * This strategy should be received from app settings but before it should be set by user. + * As this settings is required we have to block any action that requires explicit user choice + * so we will throw an error when encryption type is null + */ + async getEncryptionStrategy(): Promise { + const settings = await this.settingsProvider.getSettings(); + switch (settings.agreements?.encryption) { + case true: + return this.keytarEncryptionStrategy; + case false: + return this.plainEncryptionStrategy; + default: + throw new UnsupportedEncryptionStrategyException(); + } + } + + /** + * Encrypt data based on app encryption strategy + * @param data + */ + async encrypt(data: string): Promise { + const strategy = await this.getEncryptionStrategy(); + return strategy.encrypt(data); + } + + /** + * Try to decrypt data based on app encryption strategy + * If data was encrypted before with strategy that is not match to the current one + * it will be handled by the app encryption strategy + * @param data + * @param encryptedWith + */ + async decrypt(data: string, encryptedWith: string): Promise { + // Nothing to decrypt. Should return null then + if (!data) { + return null; + } + + const strategy = await this.getEncryptionStrategy(); + return strategy.decrypt(data, encryptedWith); + } +} diff --git a/redisinsight/api/src/modules/core/encryption/exceptions/encryption-service-error.exception.ts b/redisinsight/api/src/modules/core/encryption/exceptions/encryption-service-error.exception.ts new file mode 100644 index 0000000000..87dd96dd77 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/exceptions/encryption-service-error.exception.ts @@ -0,0 +1,11 @@ +import { HttpException } from '@nestjs/common'; + +export class EncryptionServiceErrorException extends HttpException { + constructor(response: string | Record = { + message: 'Encryption service error', + name: 'EncryptionServiceError', + statusCode: 500, + }, status = 500) { + super(response, status); + } +} diff --git a/redisinsight/api/src/modules/core/encryption/exceptions/index.ts b/redisinsight/api/src/modules/core/encryption/exceptions/index.ts new file mode 100644 index 0000000000..15fa259cda --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/exceptions/index.ts @@ -0,0 +1,5 @@ +export * from './encryption-service-error.exception'; +export * from './keytar-decryption-error.exception'; +export * from './keytar-encryption-error.exception'; +export * from './keytar-unavailable.exception'; +export * from './unsupported-encryption-strategy.exception'; diff --git a/redisinsight/api/src/modules/core/encryption/exceptions/keytar-decryption-error.exception.ts b/redisinsight/api/src/modules/core/encryption/exceptions/keytar-decryption-error.exception.ts new file mode 100644 index 0000000000..bdd4592a18 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/exceptions/keytar-decryption-error.exception.ts @@ -0,0 +1,13 @@ +import { + EncryptionServiceErrorException, +} from 'src/modules/core/encryption/exceptions/encryption-service-error.exception'; + +export class KeytarDecryptionErrorException extends EncryptionServiceErrorException { + constructor(message = 'Unable to decrypt data with Keytar') { + super({ + message, + name: 'KeytarDecryptionError', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/core/encryption/exceptions/keytar-encryption-error.exception.ts b/redisinsight/api/src/modules/core/encryption/exceptions/keytar-encryption-error.exception.ts new file mode 100644 index 0000000000..5ed87f34a7 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/exceptions/keytar-encryption-error.exception.ts @@ -0,0 +1,13 @@ +import { + EncryptionServiceErrorException, +} from 'src/modules/core/encryption/exceptions/encryption-service-error.exception'; + +export class KeytarEncryptionErrorException extends EncryptionServiceErrorException { + constructor(message = 'Unable to encrypt data with Keytar') { + super({ + message, + name: 'KeytarEncryptionError', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/core/encryption/exceptions/keytar-unavailable.exception.ts b/redisinsight/api/src/modules/core/encryption/exceptions/keytar-unavailable.exception.ts new file mode 100644 index 0000000000..180e858122 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/exceptions/keytar-unavailable.exception.ts @@ -0,0 +1,13 @@ +import { + EncryptionServiceErrorException, +} from 'src/modules/core/encryption/exceptions/encryption-service-error.exception'; + +export class KeytarUnavailableException extends EncryptionServiceErrorException { + constructor(message = 'Keytar unavailable') { + super({ + message, + name: 'KeytarUnavailable', + statusCode: 503, + }, 503); + } +} diff --git a/redisinsight/api/src/modules/core/encryption/exceptions/unsupported-encryption-strategy.exception.ts b/redisinsight/api/src/modules/core/encryption/exceptions/unsupported-encryption-strategy.exception.ts new file mode 100644 index 0000000000..b4f0d328c7 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/exceptions/unsupported-encryption-strategy.exception.ts @@ -0,0 +1,13 @@ +import { + EncryptionServiceErrorException, +} from 'src/modules/core/encryption/exceptions/encryption-service-error.exception'; + +export class UnsupportedEncryptionStrategyException extends EncryptionServiceErrorException { + constructor(message = 'Unsupported encryption strategy') { + super({ + message, + name: 'UnsupportedEncryptionStrategy', + statusCode: 500, + }, 500); + } +} diff --git a/redisinsight/api/src/modules/core/encryption/models/encryption-result.ts b/redisinsight/api/src/modules/core/encryption/models/encryption-result.ts new file mode 100644 index 0000000000..1645037f9a --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/models/encryption-result.ts @@ -0,0 +1,10 @@ +export enum EncryptionStrategy { + PLAIN = 'PLAIN', + KEYTAR = 'KEYTAR', +} + +export class EncryptionResult { + encryption?: EncryptionStrategy; + + data: string; +} diff --git a/redisinsight/api/src/modules/core/encryption/models/index.ts b/redisinsight/api/src/modules/core/encryption/models/index.ts new file mode 100644 index 0000000000..3776c52b03 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/models/index.ts @@ -0,0 +1 @@ +export * from './encryption-result'; diff --git a/redisinsight/api/src/modules/core/encryption/strategies/encryption-strategy.interface.ts b/redisinsight/api/src/modules/core/encryption/strategies/encryption-strategy.interface.ts new file mode 100644 index 0000000000..d79eacf599 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/strategies/encryption-strategy.interface.ts @@ -0,0 +1,7 @@ +import { EncryptionResult } from 'src/modules/core/encryption/models'; + +export interface IEncryptionStrategy { + encrypt(data: string): Promise; + + decrypt(data: string, encryptedWith: string): Promise; +} diff --git a/redisinsight/api/src/modules/core/encryption/strategies/keytar-encryption.strategy.spec.ts b/redisinsight/api/src/modules/core/encryption/strategies/keytar-encryption.strategy.spec.ts new file mode 100644 index 0000000000..f220ff26f7 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/strategies/keytar-encryption.strategy.spec.ts @@ -0,0 +1,141 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockDataToEncrypt, + mockEncryptResult, + mockKeytarModule, + mockKeytarPassword, +} from 'src/__mocks__'; +import { KeytarEncryptionStrategy } from 'src/modules/core/encryption/strategies/keytar-encryption.strategy'; +import { + KeytarDecryptionErrorException, + KeytarEncryptionErrorException, + KeytarUnavailableException, +} from 'src/modules/core/encryption/exceptions'; + +describe('KeytarEncryptionStrategy', () => { + let service: KeytarEncryptionStrategy; + const keytarModule = mockKeytarModule; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('keytar', () => keytarModule); + keytarModule.getPassword.mockReturnValue(mockKeytarPassword); + keytarModule.setPassword.mockReturnValue(undefined); + + const module: TestingModule = await Test.createTestingModule({ + providers: [KeytarEncryptionStrategy], + }).compile(); + + service = module.get(KeytarEncryptionStrategy); + }); + + describe('isAvailable', () => { + it('Should return true when keytar is available', async () => { + expect(await service.isAvailable()).toEqual(true); + }); + + it('Should return false when keytar is not available', async () => { + keytarModule.getPassword.mockRejectedValueOnce(new Error('Some error')); + + expect(await service.isAvailable()).toEqual(false); + }); + }); + + describe('encrypt', () => { + it('Should encrypt data', async () => { + expect(await service.encrypt(mockDataToEncrypt)).toEqual(mockEncryptResult); + + // check that cached password will be used + expect(await service.encrypt(mockDataToEncrypt)).toEqual(mockEncryptResult); + expect(mockKeytarModule.getPassword).toHaveBeenCalledTimes(1); + expect(mockKeytarModule.setPassword).not.toHaveBeenCalled(); + }); + it('Should encrypt + generate and set password when not exists yet', async () => { + keytarModule.getPassword + .mockReturnValueOnce(null) + .mockReturnValueOnce(mockKeytarPassword); + keytarModule.setPassword.mockReturnValueOnce(undefined); + + expect(await service.encrypt(mockDataToEncrypt)).toEqual(mockEncryptResult); + + expect(mockKeytarModule.setPassword).toHaveBeenCalled(); + }); + it('Should throw KeytarEncryptionError when unable to decrypt', async () => { + await expect(service.encrypt(null)).rejects.toThrowError(KeytarEncryptionErrorException); + }); + it('Should throw KeytarUnavailable in getPassword error', async () => { + keytarModule.getPassword.mockRejectedValueOnce(new Error()); + + await expect(service.encrypt(mockDataToEncrypt)).rejects.toThrowError(KeytarUnavailableException); + }); + it('Should should throw KeytarUnavailable on setPassword error', async () => { + keytarModule.getPassword + .mockReturnValueOnce(null) + .mockReturnValueOnce(mockKeytarPassword); + keytarModule.setPassword.mockRejectedValueOnce(new Error()); + + await expect(service.encrypt(mockDataToEncrypt)).rejects.toThrowError(KeytarUnavailableException); + }); + }); + + describe('decrypt', () => { + it('Should decrypt data', async () => { + expect(await service.decrypt( + mockEncryptResult.data, + mockEncryptResult.encryption, + )).toEqual(mockDataToEncrypt); + + // check that cached password will be used + expect(await service.decrypt( + mockEncryptResult.data, + mockEncryptResult.encryption, + )).toEqual(mockDataToEncrypt); + expect(mockKeytarModule.getPassword).toHaveBeenCalledTimes(1); + expect(mockKeytarModule.setPassword).not.toHaveBeenCalled(); + }); + it('Should return null when encryption doesn\'t match KEYTAR', async () => { + expect(await service.decrypt( + mockEncryptResult.data, + 'PLAIN', + )).toEqual(null); + }); + it('Should decrypt + generate and set password when not exists yet', async () => { + keytarModule.getPassword + .mockReturnValueOnce(null) + .mockReturnValueOnce(mockKeytarPassword); + keytarModule.setPassword.mockReturnValueOnce(undefined); + + expect(await service.decrypt( + mockEncryptResult.data, + mockEncryptResult.encryption, + )).toEqual(mockDataToEncrypt); + + expect(mockKeytarModule.setPassword).toHaveBeenCalled(); + }); + it('Should throw KeytarDecryptionError when unable to decrypt', async () => { + await expect(service.decrypt( + null, + mockEncryptResult.encryption, + )).rejects.toThrowError(KeytarDecryptionErrorException); + }); + it('Should throw KeytarUnavailable in getPassword error', async () => { + keytarModule.getPassword.mockRejectedValueOnce(new Error()); + + await expect(service.decrypt( + mockEncryptResult.data, + mockEncryptResult.encryption, + )).rejects.toThrowError(KeytarUnavailableException); + }); + it('Should should throw KeytarUnavailable on setPassword error', async () => { + keytarModule.getPassword + .mockReturnValueOnce(null) + .mockReturnValueOnce(mockKeytarPassword); + keytarModule.setPassword.mockRejectedValueOnce(new Error()); + + await expect(service.decrypt( + mockEncryptResult.data, + mockEncryptResult.encryption, + )).rejects.toThrowError(KeytarUnavailableException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/encryption/strategies/keytar-encryption.strategy.ts b/redisinsight/api/src/modules/core/encryption/strategies/keytar-encryption.strategy.ts new file mode 100644 index 0000000000..43769ce85d --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/strategies/keytar-encryption.strategy.ts @@ -0,0 +1,140 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + createDecipheriv, createCipheriv, randomBytes, createHash, +} from 'crypto'; +import { EncryptionResult, EncryptionStrategy } from 'src/modules/core/encryption/models'; +import { IEncryptionStrategy } from 'src/modules/core/encryption/strategies/encryption-strategy.interface'; +import { + KeytarDecryptionErrorException, + KeytarEncryptionErrorException, + KeytarUnavailableException, +} from 'src/modules/core/encryption/exceptions'; +import config from 'src/utils/config'; + +const SERVICE = 'redisinsight'; +const ACCOUNT = 'app'; +const ALGORITHM = 'aes-256-cbc'; +const SERVER_CONFIG = config.get('server'); + +@Injectable() +export class KeytarEncryptionStrategy implements IEncryptionStrategy { + private logger = new Logger('KeytarEncryptionStrategy'); + + private readonly keytar; + + private cipherKey; + + constructor() { + try { + // Have to require keytar here since during tests of keytar module + // at some point it threw an error when OS secure storage was unavailable + // Since it is difficult to reproduce we keep module require here to be + // ready for such cases + // eslint-disable-next-line global-require + this.keytar = require('keytar'); + } catch (e) { + this.logger.error('Failed to initialize keytar module'); + } + } + + /** + * Generates random password + */ + private generatePassword(): string { + return SERVER_CONFIG.secretStoragePassword || randomBytes(20).toString('base64'); + } + + /** + * Get password from the OS secret storage + * @private + */ + private async getPassword(): Promise { + try { + return await this.keytar.getPassword(SERVICE, ACCOUNT); + } catch (error) { + this.logger.error('Unable to get password'); + throw new KeytarUnavailableException(); + } + } + + /** + * Save password in the OS secret storage + * @param password + * @private + */ + private async setPassword(password: string): Promise { + try { + await this.keytar.setPassword(SERVICE, ACCOUNT, password); + } catch (error) { + this.logger.error('Unable to set password'); + throw new KeytarUnavailableException(); + } + } + + /** + * Get password from storage and create cipher key + * Note: Will generate new password if it doesn't exists yet + */ + private async getCipherKey(): Promise { + if (!this.cipherKey) { + let password = await this.getPassword(); + if (!password) { + await this.setPassword(this.generatePassword()); + password = await this.getPassword(); + } + + this.cipherKey = await createHash('sha256') + .update(password, 'utf8') + .digest(); + } + + return this.cipherKey; + } + + /** + * Checks if Keytar functionality is available + * Basically just try to get a password and checks if this call fails + */ + async isAvailable(): Promise { + try { + await this.keytar.getPassword(SERVICE, ACCOUNT); + return true; + } catch (e) { + return false; + } + } + + async encrypt(data: string): Promise { + const cipherKey = await this.getCipherKey(); + try { + const cipher = createCipheriv(ALGORITHM, cipherKey, Buffer.alloc(16, 0)); + let encrypted = cipher.update(data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return { + encryption: EncryptionStrategy.KEYTAR, + data: encrypted, + }; + } catch (error) { + this.logger.error('Unable to encrypt data', error); + throw new KeytarEncryptionErrorException(); + } + } + + async decrypt(data: string, encryptedWith: string): Promise { + if (encryptedWith !== EncryptionStrategy.KEYTAR) { + return null; + } + + const cipherKey = await this.getCipherKey(); + try { + const decipher = createDecipheriv(ALGORITHM, cipherKey, Buffer.alloc(16, 0)); + let decrypted = decipher.update(data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch (error) { + this.logger.error('Unable to decrypt data', error); + throw new KeytarDecryptionErrorException(); + } + } +} diff --git a/redisinsight/api/src/modules/core/encryption/strategies/plain-encryption.strategy.spec.ts b/redisinsight/api/src/modules/core/encryption/strategies/plain-encryption.strategy.spec.ts new file mode 100644 index 0000000000..663dcc4c72 --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/strategies/plain-encryption.strategy.spec.ts @@ -0,0 +1,43 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockDataToEncrypt, + mockEncryptResult, +} from 'src/__mocks__'; +import { PlainEncryptionStrategy } from 'src/modules/core/encryption/strategies/plain-encryption.strategy'; +import { EncryptionStrategy } from 'src/modules/core/encryption/models'; + +describe('PlainEncryptionStrategy', () => { + let service: PlainEncryptionStrategy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PlainEncryptionStrategy], + }).compile(); + + service = module.get(PlainEncryptionStrategy); + }); + + describe('encrypt', () => { + it('Should return unencrypted data', async () => { + expect(await service.encrypt(mockDataToEncrypt)).toEqual({ + data: mockDataToEncrypt, + encryption: EncryptionStrategy.PLAIN, + }); + }); + }); + + describe('decrypt', () => { + it('Should return plain data', async () => { + expect(await service.decrypt( + mockEncryptResult.data, + EncryptionStrategy.PLAIN, + )).toEqual(mockEncryptResult.data); + }); + it('Should return null when encryption doesn\'t match PLAIN', async () => { + expect(await service.decrypt( + mockEncryptResult.data, + 'KEYTAR', + )).toEqual(null); + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/encryption/strategies/plain-encryption.strategy.ts b/redisinsight/api/src/modules/core/encryption/strategies/plain-encryption.strategy.ts new file mode 100644 index 0000000000..cb447fa35c --- /dev/null +++ b/redisinsight/api/src/modules/core/encryption/strategies/plain-encryption.strategy.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { EncryptionResult, EncryptionStrategy } from 'src/modules/core/encryption/models'; +import { IEncryptionStrategy } from 'src/modules/core/encryption/strategies/encryption-strategy.interface'; + +@Injectable() +export class PlainEncryptionStrategy implements IEncryptionStrategy { + async encrypt(data: string): Promise { + return { + encryption: EncryptionStrategy.PLAIN, + data, + }; + } + + async decrypt(data: string, encryptedWith: string): Promise { + if (encryptedWith !== EncryptionStrategy.PLAIN) { + return null; + } + + return data; + } +} diff --git a/redisinsight/api/src/modules/core/interceptors/timeout.interceptor.ts b/redisinsight/api/src/modules/core/interceptors/timeout.interceptor.ts new file mode 100644 index 0000000000..b07d03d92b --- /dev/null +++ b/redisinsight/api/src/modules/core/interceptors/timeout.interceptor.ts @@ -0,0 +1,38 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + RequestTimeoutException, + Logger, +} from '@nestjs/common'; +import { Observable, throwError, TimeoutError } from 'rxjs'; +import { catchError, timeout } from 'rxjs/operators'; +import config from 'src/utils/config'; + +const serverConfig = config.get('server'); + +@Injectable() +export class TimeoutInterceptor implements NestInterceptor { + private logger = new Logger('TimeoutInterceptor'); + + private readonly message: string; + + constructor(message?: string) { + this.message = message; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + timeout(serverConfig.requestTimeout), + catchError((err) => { + if (err instanceof TimeoutError) { + const { method, url } = context.switchToHttp().getRequest(); + this.logger.error(`Request Timeout. ${method} ${url}`); + return throwError(new RequestTimeoutException(this.message)); + } + return throwError(err); + }), + ); + } +} diff --git a/redisinsight/api/src/modules/core/models/agreements.entity.ts b/redisinsight/api/src/modules/core/models/agreements.entity.ts new file mode 100644 index 0000000000..2ae6e32881 --- /dev/null +++ b/redisinsight/api/src/modules/core/models/agreements.entity.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + +export interface IAgreementsJSON { + version: string; +} + +@Entity('agreements') +export class AgreementsEntity { + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ + description: 'Last accepted version.', + type: String, + }) + @Column({ nullable: true }) + version: string; + + @ApiProperty({ + description: 'User agreements.', + type: String, + }) + @Column({ nullable: true }) + data: string; + + toJSON(): IAgreementsJSON { + const { version, data } = this; + try { + return { + version, + ...JSON.parse(data), + }; + } catch (e) { + return { version: null }; + } + } +} diff --git a/redisinsight/api/src/modules/core/models/ca-certificate.entity.ts b/redisinsight/api/src/modules/core/models/ca-certificate.entity.ts new file mode 100644 index 0000000000..08e1d55365 --- /dev/null +++ b/redisinsight/api/src/modules/core/models/ca-certificate.entity.ts @@ -0,0 +1,38 @@ +import { + Column, Entity, OneToMany, PrimaryGeneratedColumn, +} from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; +import { DatabaseInstanceEntity } from 'src/modules/core/models/database-instance.entity'; + +@Entity('ca_certificate') +export class CaCertificateEntity { + @PrimaryGeneratedColumn('uuid') + @ApiProperty({ + description: 'Certificate id', + type: String, + }) + id: string; + + @ApiProperty({ + description: 'A name for certificate.', + type: String, + }) + @Column({ nullable: false, unique: true }) + name: string; + + @Exclude() + @Column({ nullable: true }) + encryption: string; + + @Exclude() + @Column({ nullable: true }) + certificate: string; + + @OneToMany(() => DatabaseInstanceEntity, (database) => database.caCert) + public databases: DatabaseInstanceEntity[]; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/redisinsight/api/src/modules/core/models/client-certificate.entity.ts b/redisinsight/api/src/modules/core/models/client-certificate.entity.ts new file mode 100644 index 0000000000..1ede7dfbde --- /dev/null +++ b/redisinsight/api/src/modules/core/models/client-certificate.entity.ts @@ -0,0 +1,42 @@ +import { + Column, Entity, OneToMany, PrimaryGeneratedColumn, +} from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; +import { DatabaseInstanceEntity } from 'src/modules/core/models/database-instance.entity'; + +@Entity('client_certificate') +export class ClientCertificateEntity { + @PrimaryGeneratedColumn('uuid') + @ApiProperty({ + description: 'Certificate id', + type: String, + }) + id: string; + + @ApiProperty({ + description: 'A name for certificate.', + type: String, + }) + @Column({ nullable: false, unique: true }) + name: string; + + @Exclude() + @Column({ nullable: true }) + encryption: string; + + @Exclude() + @Column({ nullable: true }) + certificate: string; + + @Exclude() + @Column({ nullable: true }) + key: string; + + @OneToMany(() => DatabaseInstanceEntity, (database) => database.clientCert) + public databases: DatabaseInstanceEntity[]; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/redisinsight/api/src/modules/core/models/database-instance.entity.ts b/redisinsight/api/src/modules/core/models/database-instance.entity.ts new file mode 100644 index 0000000000..8625e1e6e9 --- /dev/null +++ b/redisinsight/api/src/modules/core/models/database-instance.entity.ts @@ -0,0 +1,198 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + Column, Entity, ManyToOne, PrimaryGeneratedColumn, +} from 'typeorm'; +import { CaCertificateEntity } from 'src/modules/core/models/ca-certificate.entity'; +import { ClientCertificateEntity } from 'src/modules/core/models/client-certificate.entity'; + +export enum HostingProvider { + UNKNOWN = 'UNKNOWN', + LOCALHOST = 'LOCALHOST', + RE_CLUSTER = 'RE_CLUSTER', + RE_CLOUD = 'RE_CLOUD', + AZURE = 'AZURE', + AWS = 'AWS', + GOOGLE = 'GOOGLE', +} + +export enum ConnectionType { + STANDALONE = 'STANDALONE', + CLUSTER = 'CLUSTER', + SENTINEL = 'SENTINEL', +} + +@Entity('database_instance') +export class DatabaseInstanceEntity { + @PrimaryGeneratedColumn('uuid') + @ApiProperty({ + description: 'Database id.', + type: String, + }) + id: string; + + @ApiProperty({ + description: + 'The hostname of your Redis database, for example redis.acme.com.', + type: String, + }) + @Column({ nullable: false }) + host: string; + + @ApiProperty({ + description: 'The port your Redis database is available on.', + type: Number, + }) + @Column({ nullable: false }) + port: number; + + @ApiProperty({ + description: 'A name for Redis database.', + type: String, + }) + @Column({ nullable: false }) + name: string; + + @ApiPropertyOptional({ + description: 'Logical database number.', + type: Number, + example: 0, + }) + @Column({ nullable: true }) + db: number; + + @ApiPropertyOptional({ + description: 'The username, if your database is ACL enabled.', + type: String, + }) + @Column({ nullable: true }) + username: string; + + @ApiPropertyOptional({ + description: 'The password for your Redis database.', + type: String, + }) + @Column({ nullable: true }) + password: string; + + @ApiPropertyOptional({ + description: + 'Sentinel master group name. Identifies a group of Redis instances composed of a master and one or more slaves.', + type: String, + }) + @Column({ nullable: true }) + sentinelMasterName: string; + + @ApiPropertyOptional({ + description: 'The username, if your Sentinel master is ACL enabled.', + type: String, + }) + @Column({ nullable: true }) + sentinelMasterUsername: string; + + @ApiPropertyOptional({ + description: 'The password for your Redis Sentinel master.', + type: String, + }) + @Column({ nullable: true }) + sentinelMasterPassword: string; + + @ApiProperty({ + description: 'Use TLS to connect.', + type: Boolean, + }) + @Column({ nullable: false }) + tls: boolean; + + @ApiProperty({ + description: 'The certificate returned by the server needs to be verified.', + type: Boolean, + }) + @Column({ nullable: false }) + verifyServerCert: boolean; + + @ApiProperty({ + description: 'CA Certificate.', + type: () => CaCertificateEntity, + }) + @ManyToOne( + () => CaCertificateEntity, + (caCertificate) => caCertificate.databases, + { + eager: true, + onDelete: 'SET NULL', + }, + ) + caCert: CaCertificateEntity; + + @ApiProperty({ + description: 'Client Certificate.', + type: () => ClientCertificateEntity, + }) + @ManyToOne( + () => ClientCertificateEntity, + (clientCertificate) => clientCertificate.databases, + { + eager: true, + onDelete: 'SET NULL', + }, + ) + clientCert: ClientCertificateEntity; + + @ApiProperty({ + description: 'Connection Type', + default: ConnectionType.STANDALONE, + enum: ConnectionType, + }) + @Column({ + nullable: false, + default: ConnectionType.STANDALONE, + }) + connectionType: ConnectionType; + + @ApiPropertyOptional({ + description: 'The database name from provider', + type: String, + }) + @Column({ nullable: true }) + nameFromProvider: string; + + @ApiPropertyOptional({ + description: 'OSS Cluster nodes.', + type: String, + }) + @Column({ nullable: true }) + nodes: string; + + @ApiProperty({ + description: 'Time of the last connection to the database', + type: String, + format: 'date-time', + example: '2021-01-06T12:44:39.000Z', + }) + @Column({ type: 'datetime', nullable: true }) + lastConnection: Date; + + @ApiProperty({ + description: 'Database Provider', + type: String, + }) + @Column({ + nullable: true, + default: HostingProvider.UNKNOWN, + }) + provider: string; + + @ApiProperty({ + description: 'Loaded Redis modules.', + type: String, + }) + @Column({ nullable: false, default: '[]' }) + modules: string; + + @Column({ nullable: true }) + encryption: string; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/redisinsight/api/src/modules/core/models/server-provider.interface.ts b/redisinsight/api/src/modules/core/models/server-provider.interface.ts new file mode 100644 index 0000000000..5130c4c551 --- /dev/null +++ b/redisinsight/api/src/modules/core/models/server-provider.interface.ts @@ -0,0 +1,5 @@ +import { GetServerInfoResponse } from 'src/dto/server.dto'; + +export interface IServerProvider { + getInfo(): Promise; +} diff --git a/redisinsight/api/src/modules/core/models/server.entity.ts b/redisinsight/api/src/modules/core/models/server.entity.ts new file mode 100644 index 0000000000..b9160cf575 --- /dev/null +++ b/redisinsight/api/src/modules/core/models/server.entity.ts @@ -0,0 +1,10 @@ +import { Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; + +@Entity('server') +export class ServerEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @CreateDateColumn({ type: 'datetime', nullable: false }) + createDateTime: string; +} diff --git a/redisinsight/api/src/modules/core/models/settings-provider.interface.ts b/redisinsight/api/src/modules/core/models/settings-provider.interface.ts new file mode 100644 index 0000000000..859c39e267 --- /dev/null +++ b/redisinsight/api/src/modules/core/models/settings-provider.interface.ts @@ -0,0 +1,13 @@ +import { + GetAgreementsSpecResponse, + GetAppSettingsResponse, + UpdateSettingsDto, +} from 'src/dto/settings.dto'; + +export interface ISettingsProvider { + getSettings(): Promise; + + updateSettings(dto: UpdateSettingsDto): Promise; + + getAgreementsSpec(): Promise; +} diff --git a/redisinsight/api/src/modules/core/models/settings.entity.ts b/redisinsight/api/src/modules/core/models/settings.entity.ts new file mode 100644 index 0000000000..9ec36fc643 --- /dev/null +++ b/redisinsight/api/src/modules/core/models/settings.entity.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +export interface ISettingsJSON { + theme: string; + scanThreshold: number; +} + +const defaultData: ISettingsJSON = { + theme: null, + scanThreshold: null, +}; + +@Entity('settings') +export class SettingsEntity { + @PrimaryGeneratedColumn() + id: number; + + @ApiProperty({ + description: 'Applied settings by user.', + type: String, + }) + @Column({ nullable: true }) + data: string; + + toJSON(): ISettingsJSON { + const { data } = this; + try { + return { + ...defaultData, + ...JSON.parse(data), + }; + } catch (e) { + return { + ...defaultData, + }; + } + } +} diff --git a/redisinsight/api/src/modules/core/providers/server-on-premise/index.ts b/redisinsight/api/src/modules/core/providers/server-on-premise/index.ts new file mode 100644 index 0000000000..70fac4d008 --- /dev/null +++ b/redisinsight/api/src/modules/core/providers/server-on-premise/index.ts @@ -0,0 +1,14 @@ +import { ServerRepository } from 'src/modules/core/repositories/server.repository'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; +import { ServerOnPremiseService } from './server-on-premise.service'; + +export default { + provide: 'SERVER_PROVIDER', + useFactory: ( + repository: ServerRepository, + eventEmitter: EventEmitter2, + encryptionService: EncryptionService, + ) => new ServerOnPremiseService(repository, eventEmitter, encryptionService), + inject: [ServerRepository, EventEmitter2, EncryptionService], +}; diff --git a/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.spec.ts b/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.spec.ts new file mode 100644 index 0000000000..e77b7bee33 --- /dev/null +++ b/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.spec.ts @@ -0,0 +1,169 @@ +import { TestingModule, Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { InternalServerErrorException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Repository } from 'typeorm'; +import { mockEncryptionService, mockRepository, MockType } from 'src/__mocks__'; +import config from 'src/utils/config'; +import { + ServerInfoNotFoundException, + AppAnalyticsEvents, + TelemetryEvents, +} from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { ServerEntity } from 'src/modules/core/models/server.entity'; +import { ITelemetryEvent } from 'src/modules/core/services/analytics/analytics.service'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; +import { EncryptionStrategy } from 'src/modules/core/encryption/models'; +import { ServerOnPremiseService } from './server-on-premise.service'; + +const SERVER_CONFIG = config.get('server'); + +const mockServerEntity: ServerEntity = { + id: 'a77b23c1-7816-4ea4-b61f-d37795a0f805', + createDateTime: '2021-01-06T12:44:39.000Z', +}; + +const mockEventPayload: ITelemetryEvent = { + event: TelemetryEvents.ApplicationStarted, + eventData: { + appVersion: SERVER_CONFIG.appVersion, + osPlatform: process.platform, + buildType: SERVER_CONFIG.buildType, + }, + nonTracking: true, +}; + +describe('ServerOnPremiseService', () => { + let service: ServerOnPremiseService; + let serverRepository: MockType>; + let eventEmitter: EventEmitter2; + let encryptionService: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + { + provide: getRepositoryToken(ServerEntity), + useFactory: mockRepository, + }, + { + provide: EncryptionService, + useFactory: mockEncryptionService, + }, + ], + }).compile(); + + serverRepository = await module.get(getRepositoryToken(ServerEntity)); + eventEmitter = await module.get(EventEmitter2); + encryptionService = module.get(EncryptionService); + service = new ServerOnPremiseService(serverRepository, eventEmitter, encryptionService); + }); + + describe('onApplicationBootstrap', () => { + beforeEach(() => { + eventEmitter.emit = jest.fn(); + }); + it('should create server instance on first application launch', async () => { + serverRepository.findOne.mockResolvedValue(null); + serverRepository.create.mockReturnValue(mockServerEntity); + + await service.onApplicationBootstrap(); + + expect(serverRepository.findOne).toHaveBeenCalled(); + expect(serverRepository.create).toHaveBeenCalled(); + expect(serverRepository.save).toHaveBeenCalledWith(mockServerEntity); + }); + it('should not create server instance on the second application launch', async () => { + serverRepository.findOne.mockResolvedValue(mockServerEntity); + + await service.onApplicationBootstrap(); + + expect(serverRepository.findOne).toHaveBeenCalled(); + expect(serverRepository.create).not.toHaveBeenCalled(); + expect(serverRepository.save).not.toHaveBeenCalled(); + }); + it('should emit APPLICATION_FIRST_START on first application launch', async () => { + serverRepository.findOne.mockResolvedValue(null); + serverRepository.create.mockReturnValue(mockServerEntity); + + await service.onApplicationBootstrap(); + + expect(eventEmitter.emit).toHaveBeenNthCalledWith( + 1, + AppAnalyticsEvents.Initialize, + mockServerEntity.id, + ); + expect(eventEmitter.emit).toHaveBeenNthCalledWith( + 2, + AppAnalyticsEvents.Track, + { + ...mockEventPayload, + event: TelemetryEvents.ApplicationFirstStart, + }, + ); + }); + it('should emit APPLICATION_STARTED on second application launch', async () => { + serverRepository.findOne.mockResolvedValue(mockServerEntity); + + await service.onApplicationBootstrap(); + + expect(eventEmitter.emit).toHaveBeenNthCalledWith( + 1, + AppAnalyticsEvents.Initialize, + mockServerEntity.id, + ); + expect(eventEmitter.emit).toHaveBeenNthCalledWith( + 2, + AppAnalyticsEvents.Track, + { + ...mockEventPayload, + event: TelemetryEvents.ApplicationStarted, + }, + ); + }); + }); + + describe('getInfo', () => { + it('should return server info', async () => { + serverRepository.findOne.mockResolvedValue(mockServerEntity); + encryptionService.getAvailableEncryptionStrategies.mockResolvedValue([ + EncryptionStrategy.PLAIN, + EncryptionStrategy.KEYTAR, + ]); + const result = await service.getInfo(); + + expect(result).toEqual({ + ...mockServerEntity, + appVersion: SERVER_CONFIG.appVersion, + osPlatform: process.platform, + buildType: SERVER_CONFIG.buildType, + encryptionStrategies: [ + EncryptionStrategy.PLAIN, + EncryptionStrategy.KEYTAR, + ], + }); + }); + it('should throw ServerInfoNotFoundException', async () => { + serverRepository.findOne.mockResolvedValue(null); + + try { + await service.getInfo(); + } catch (err) { + expect(err).toBeInstanceOf(ServerInfoNotFoundException); + expect(err.message).toEqual(ERROR_MESSAGES.SERVER_INFO_NOT_FOUND()); + } + }); + it('should throw InternalServerError', async () => { + serverRepository.findOne.mockRejectedValue(new Error('some error')); + + try { + await service.getInfo(); + fail('Should throw an error'); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.ts b/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.ts new file mode 100644 index 0000000000..5a03b20ac2 --- /dev/null +++ b/redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.ts @@ -0,0 +1,97 @@ +import { + Injectable, + InternalServerErrorException, + Logger, + OnApplicationBootstrap, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import config from 'src/utils/config'; +import { AppAnalyticsEvents } from 'src/constants/app-events'; +import { TelemetryEvents } from 'src/constants/telemetry-events'; +import { GetServerInfoResponse } from 'src/dto/server.dto'; +import { ServerRepository } from 'src/modules/core/repositories/server.repository'; +import { IServerProvider } from 'src/modules/core/models/server-provider.interface'; +import { ServerInfoNotFoundException } from 'src/constants/exceptions'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; + +const SERVER_CONFIG = config.get('server'); + +@Injectable() +export class ServerOnPremiseService +implements OnApplicationBootstrap, IServerProvider { + private logger = new Logger('ServerOnPremiseService'); + + private repository: ServerRepository; + + private eventEmitter: EventEmitter2; + + private encryptionService: EncryptionService; + + constructor(repository, eventEmitter, encryptionService) { + this.repository = repository; + this.eventEmitter = eventEmitter; + this.encryptionService = encryptionService; + } + + async onApplicationBootstrap() { + await this.upsertServerInfo(); + } + + private async upsertServerInfo() { + this.logger.log('Checking server info.'); + let serverInfo = await this.repository.findOne(); + if (!serverInfo) { + this.logger.log('First application launch.'); + // Create default server info on first application launch + serverInfo = this.repository.create({}); + await this.repository.save(serverInfo); + this.eventEmitter.emit(AppAnalyticsEvents.Initialize, serverInfo.id); + this.eventEmitter.emit(AppAnalyticsEvents.Track, { + event: TelemetryEvents.ApplicationFirstStart, + eventData: { + appVersion: SERVER_CONFIG.appVersion, + osPlatform: process.platform, + buildType: SERVER_CONFIG.buildType, + }, + nonTracking: true, + }); + } else { + this.logger.log('Application started.'); + this.eventEmitter.emit(AppAnalyticsEvents.Initialize, serverInfo.id); + this.eventEmitter.emit(AppAnalyticsEvents.Track, { + event: TelemetryEvents.ApplicationStarted, + eventData: { + appVersion: SERVER_CONFIG.appVersion, + osPlatform: process.platform, + buildType: SERVER_CONFIG.buildType, + }, + nonTracking: true, + }); + } + } + + /** + * Method to get server info + */ + public async getInfo(): Promise { + this.logger.log('Getting server info.'); + try { + const info = await this.repository.findOne(); + if (!info) { + return Promise.reject(new ServerInfoNotFoundException()); + } + const result = { + ...info, + appVersion: SERVER_CONFIG.appVersion, + osPlatform: process.platform, + buildType: SERVER_CONFIG.buildType, + encryptionStrategies: await this.encryptionService.getAvailableEncryptionStrategies(), + }; + this.logger.log('Succeed to get server info.'); + return result; + } catch (error) { + this.logger.error('Failed to get application settings.', error); + throw new InternalServerErrorException(); + } + } +} diff --git a/redisinsight/api/src/modules/core/providers/settings-on-premise/index.ts b/redisinsight/api/src/modules/core/providers/settings-on-premise/index.ts new file mode 100644 index 0000000000..999d4c3661 --- /dev/null +++ b/redisinsight/api/src/modules/core/providers/settings-on-premise/index.ts @@ -0,0 +1,26 @@ +import { SettingsRepository } from 'src/modules/core/repositories/settings.repository'; +import { SettingsAnalyticsService } from 'src/modules/core/services/settings-analytics/settings-analytics.service'; +import { KeytarEncryptionStrategy } from 'src/modules/core/encryption/strategies/keytar-encryption.strategy'; +import { SettingsOnPremiseService } from './settings-on-premise.service'; +import { AgreementsRepository } from '../../repositories/agreements.repository'; + +export default { + provide: 'SETTINGS_PROVIDER', + useFactory: ( + agreementsRepository: AgreementsRepository, + settingsRepository: SettingsRepository, + analyticsService: SettingsAnalyticsService, + keytarEncryptionStrategy: KeytarEncryptionStrategy, + ) => new SettingsOnPremiseService( + agreementsRepository, + settingsRepository, + analyticsService, + keytarEncryptionStrategy, + ), + inject: [ + AgreementsRepository, + SettingsRepository, + SettingsAnalyticsService, + KeytarEncryptionStrategy, + ], +}; diff --git a/redisinsight/api/src/modules/core/providers/settings-on-premise/settings-on-premise.service.spec.ts b/redisinsight/api/src/modules/core/providers/settings-on-premise/settings-on-premise.service.spec.ts new file mode 100644 index 0000000000..a3a162a27b --- /dev/null +++ b/redisinsight/api/src/modules/core/providers/settings-on-premise/settings-on-premise.service.spec.ts @@ -0,0 +1,270 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { InternalServerErrorException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { + mockAgreementsEntity, + mockAgreementsJSON, mockEncryptionStrategy, + mockRepository, + mockSettingsAnalyticsService, + mockSettingsEntity, + mockSettingsJSON, + MockType, +} from 'src/__mocks__'; +import { UpdateSettingsDto } from 'src/dto/settings.dto'; +import * as AGREEMENTS_SPEC from 'src/constants/agreements-spec.json'; +import { AgreementIsNotDefinedException } from 'src/constants'; +import config from 'src/utils/config'; +import { SettingsEntity } from 'src/modules/core/models/settings.entity'; +import { AgreementsEntity } from 'src/modules/core/models/agreements.entity'; +import { SettingsAnalyticsService } from 'src/modules/core/services/settings-analytics/settings-analytics.service'; +import { EncryptionStrategy } from 'src/modules/core/encryption/models'; +import { KeytarEncryptionStrategy } from 'src/modules/core/encryption/strategies/keytar-encryption.strategy'; +import { SettingsOnPremiseService } from './settings-on-premise.service'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +const mockAgreementsMap = new Map( + Object.keys(AGREEMENTS_SPEC.agreements).map((item: string) => [ + item, + true, + ]), +); + +describe('SettingsOnPremiseService', () => { + let service: SettingsOnPremiseService; + let agreementsRepository: MockType>; + let settingsRepository: MockType>; + let agreementsEntity: AgreementsEntity; + let settingsEntity: SettingsEntity; + let analyticsService: SettingsAnalyticsService; + let keytarEncryptionStrategy: KeytarEncryptionStrategy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: SettingsAnalyticsService, + useFactory: mockSettingsAnalyticsService, + }, + { + provide: getRepositoryToken(AgreementsEntity), + useFactory: mockRepository, + }, + { + provide: getRepositoryToken(SettingsEntity), + useFactory: mockRepository, + }, + { + provide: KeytarEncryptionStrategy, + useFactory: mockEncryptionStrategy, + }, + ], + }).compile(); + + settingsEntity = { + ...mockSettingsEntity, + toJSON: jest.fn().mockReturnValue({ ...mockSettingsJSON }), + }; + agreementsEntity = { + ...mockAgreementsEntity, + toJSON: jest.fn().mockReturnValue({ ...mockAgreementsJSON }), + }; + agreementsRepository = await module.get( + getRepositoryToken(AgreementsEntity), + ); + settingsRepository = await module.get(getRepositoryToken(SettingsEntity)); + analyticsService = await module.get(SettingsAnalyticsService); + keytarEncryptionStrategy = await module.get(KeytarEncryptionStrategy); + service = new SettingsOnPremiseService( + agreementsRepository, + settingsRepository, + analyticsService, + keytarEncryptionStrategy, + ); + }); + + describe('onModuleInit', () => { + it('should create settings and agreements instance on first application launch', async () => { + agreementsRepository.findOne.mockResolvedValue(null); + agreementsRepository.create.mockReturnValue(agreementsEntity); + settingsRepository.findOne.mockResolvedValue(null); + settingsRepository.create.mockReturnValue(settingsEntity); + + await service.onModuleInit(); + + expect(agreementsRepository.findOne).toHaveBeenCalled(); + expect(settingsRepository.findOne).toHaveBeenCalled(); + expect(agreementsRepository.create).toHaveBeenCalled(); + expect(settingsRepository.create).toHaveBeenCalled(); + expect(agreementsRepository.save).toHaveBeenCalledWith(agreementsEntity); + expect(settingsRepository.save).toHaveBeenCalledWith(settingsEntity); + }); + it('should not create settings and agreements on the second application launch', async () => { + agreementsRepository.findOne.mockResolvedValue(agreementsEntity); + settingsRepository.findOne.mockResolvedValue(settingsEntity); + + await service.onModuleInit(); + + expect(agreementsRepository.findOne).toHaveBeenCalled(); + expect(agreementsRepository.create).not.toHaveBeenCalled(); + expect(agreementsRepository.save).not.toHaveBeenCalled(); + expect(settingsRepository.findOne).toHaveBeenCalled(); + expect(settingsRepository.create).not.toHaveBeenCalled(); + expect(settingsRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('getSettings', () => { + it('should return default application settings', async () => { + agreementsRepository.findOne.mockResolvedValue(agreementsEntity); + settingsRepository.findOne.mockResolvedValue(settingsEntity); + + const result = await service.getSettings(); + + expect(result).toEqual({ + theme: null, + scanThreshold: REDIS_SCAN_CONFIG.countThreshold, + agreements: null, + }); + }); + it('should return some application settings already defined by user', async () => { + settingsEntity.toJSON = jest.fn().mockReturnValue({ + ...mockSettingsJSON, + theme: 'DARK', + scanThreshold: 500, + encryptionStrategy: EncryptionStrategy.KEYTAR, + }); + agreementsEntity.toJSON = jest.fn().mockReturnValue({ + ...mockAgreementsJSON, + version: '1.0.0', + eula: true, + }); + agreementsRepository.findOne.mockResolvedValue(agreementsEntity); + settingsRepository.findOne.mockResolvedValue(settingsEntity); + + const result = await service.getSettings(); + + expect(result).toEqual({ + ...mockSettingsJSON, + theme: 'DARK', + scanThreshold: 500, + encryptionStrategy: EncryptionStrategy.KEYTAR, + agreements: { + version: '1.0.0', + eula: true, + }, + }); + }); + it('should throw InternalServerError', async () => { + agreementsRepository.findOne.mockRejectedValue(new Error('some error')); + + try { + await service.getSettings(); + fail('Should throw an error'); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + }); + + describe('updateSettings', () => { + beforeEach(() => { + settingsEntity.toJSON = jest.fn().mockReturnValue({ + ...mockSettingsJSON, + }); + agreementsEntity.toJSON = jest.fn().mockReturnValue({ + ...mockAgreementsJSON, + }); + settingsRepository.findOne.mockResolvedValue(settingsEntity); + agreementsRepository.findOne.mockResolvedValue(agreementsEntity); + service.getSettings = jest.fn(); + }); + it('should update agreements and settings', async () => { + const dto: UpdateSettingsDto = { + scanThreshold: 1000, + agreements: mockAgreementsMap, + }; + const mockUpdatedAgreements = { + ...agreementsEntity, + version: AGREEMENTS_SPEC.version, + data: JSON.stringify(Object.fromEntries(dto.agreements)), + }; + + await service.updateSettings(dto); + + expect(agreementsRepository.save).toHaveBeenCalledWith( + mockUpdatedAgreements, + ); + expect(settingsRepository.save).toHaveBeenCalledWith({ + ...settingsEntity, + data: JSON.stringify({ ...mockSettingsJSON, scanThreshold: 1000 }), + }); + expect(service.getSettings).toHaveBeenCalled(); + expect(analyticsService.sendAnalyticsAgreementChange).toHaveBeenCalled(); + }); + it('should update only settings', async () => { + const dto: UpdateSettingsDto = { + scanThreshold: 1000, + }; + + await service.updateSettings(dto); + + expect(settingsRepository.save).toHaveBeenCalledWith({ + ...settingsEntity, + data: JSON.stringify({ + ...mockSettingsJSON, + scanThreshold: 1000, + }), + }); + expect(service.getSettings).toHaveBeenCalled(); + expect(agreementsRepository.save).not.toHaveBeenCalled(); + expect(analyticsService.sendAnalyticsAgreementChange).not.toHaveBeenCalled(); + }); + it('should update only agreements', async () => { + const dto: UpdateSettingsDto = { + agreements: mockAgreementsMap, + }; + const mockUpdatedAgreements = { + ...agreementsEntity, + version: AGREEMENTS_SPEC.version, + data: JSON.stringify(Object.fromEntries(dto.agreements)), + }; + + await service.updateSettings(dto); + + expect(agreementsRepository.save).toHaveBeenCalledWith( + mockUpdatedAgreements, + ); + expect(settingsRepository.save).not.toHaveBeenCalled(); + expect(service.getSettings).toHaveBeenCalled(); + expect(analyticsService.sendAnalyticsAgreementChange).toHaveBeenCalled(); + }); + it('should throw AgreementIsNotDefinedException', async () => { + agreementsRepository.findOne.mockResolvedValueOnce({ + id: 1, + version: null, + data: null, + }); + + try { + await service.updateSettings({ agreements: new Map([]) }); + } catch (err) { + expect(err).toBeInstanceOf(AgreementIsNotDefinedException); + } + }); + it('should throw InternalServerError', async () => { + const dto: UpdateSettingsDto = { + agreements: mockAgreementsMap, + }; + agreementsRepository.findOne.mockRejectedValue(new Error('some error')); + + try { + await service.updateSettings(dto); + fail('Should throw an error'); + } catch (err) { + expect(err).toBeInstanceOf(InternalServerErrorException); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/providers/settings-on-premise/settings-on-premise.service.ts b/redisinsight/api/src/modules/core/providers/settings-on-premise/settings-on-premise.service.ts new file mode 100644 index 0000000000..a78c0ed53c --- /dev/null +++ b/redisinsight/api/src/modules/core/providers/settings-on-premise/settings-on-premise.service.ts @@ -0,0 +1,193 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + OnModuleInit, +} from '@nestjs/common'; +import { + difference, + isEmpty, + map, + cloneDeep, +} from 'lodash'; +import * as AGREEMENTS_SPEC from 'src/constants/agreements-spec.json'; +import config from 'src/utils/config'; +import { AgreementIsNotDefinedException } from 'src/constants'; +import { GetAgreementsSpecResponse, GetAppSettingsResponse, UpdateSettingsDto } from 'src/dto/settings.dto'; +import { AgreementsEntity, IAgreementsJSON } from 'src/modules/core/models/agreements.entity'; +import { ISettingsJSON, SettingsEntity } from 'src/modules/core/models/settings.entity'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { KeytarEncryptionStrategy } from 'src/modules/core/encryption/strategies/keytar-encryption.strategy'; +import { AgreementsRepository } from '../../repositories/agreements.repository'; +import { SettingsRepository } from '../../repositories/settings.repository'; +import { SettingsAnalyticsService } from '../../services/settings-analytics/settings-analytics.service'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); + +@Injectable() +export class SettingsOnPremiseService +implements OnModuleInit, ISettingsProvider { + private logger = new Logger('SettingsOnPremiseService'); + + private agreementRepository: AgreementsRepository; + + private settingsRepository: SettingsRepository; + + private analyticsService: SettingsAnalyticsService; + + private keytarEncryptionStrategy: KeytarEncryptionStrategy; + + constructor(agreementRepository, settingsRepository, analyticsService, keytarEncryptionStrategy) { + this.agreementRepository = agreementRepository; + this.settingsRepository = settingsRepository; + this.analyticsService = analyticsService; + this.keytarEncryptionStrategy = keytarEncryptionStrategy; + } + + async onModuleInit() { + await this.upsertSettings(); + } + + private async upsertSettings() { + const agreementsEntity = await this.agreementRepository.findOne(); + const settingsEntity = await this.settingsRepository.findOne(); + if (!agreementsEntity) { + const agreements: AgreementsEntity = this.agreementRepository.create({}); + await this.agreementRepository.save(agreements); + } + if (!settingsEntity) { + const settings: SettingsEntity = this.settingsRepository.create({}); + await this.settingsRepository.save(settings); + } + } + + /** + * Method to get settings + */ + public async getSettings(): Promise { + this.logger.log('Getting application settings.'); + try { + const agreements: IAgreementsJSON = ( + await this.agreementRepository.findOne() + ).toJSON(); + const settings: ISettingsJSON = ( + await this.settingsRepository.findOne() + ).toJSON(); + this.logger.log('Succeed to get application settings.'); + return { + ...settings, + scanThreshold: settings.scanThreshold || REDIS_SCAN_CONFIG.countThreshold, + agreements: agreements.version ? agreements : null, + }; + } catch (error) { + this.logger.error('Failed to get application settings.', error); + throw new InternalServerErrorException(); + } + } + + /** + * Method to update application settings and agreements + * @param dto + */ + public async updateSettings( + dto: UpdateSettingsDto, + ): Promise { + this.logger.log('Updating application settings.'); + const { agreements, ...settings } = dto; + try { + const oldSettings = await this.getSettings(); + if (!isEmpty(settings)) { + const entity: SettingsEntity = await this.settingsRepository.findOne(); + + entity.data = JSON.stringify({ + ...entity.toJSON(), + ...settings, + }); + await this.settingsRepository.save(entity); + } + if (agreements) { + await this.updateAgreements(dto.agreements); + } + this.logger.log('Succeed to update application settings.'); + const results = await this.getSettings(); + this.analyticsService.sendSettingsUpdatedEvent(results, oldSettings); + return results; + } catch (error) { + this.logger.error('Failed to update application settings.', error); + if ( + error instanceof AgreementIsNotDefinedException + || error instanceof BadRequestException + ) { + throw error; + } + throw new InternalServerErrorException(); + } + } + + /** + * Call for current system's state check for conditional agreements spec + * @param checker + * @param defaultOption + * @private + */ + private async getAgreementsOption(checker: string, defaultOption: string): Promise { + try { + if (checker === 'KEYTAR') { + return `${await this.keytarEncryptionStrategy.isAvailable()}`; + } + } catch (e) { + this.logger.error(`Unable to proceed agreements checker ${checker}`); + } + + return defaultOption; + } + + /** + * Process conditional agreements where needed and returns proper agreements spec + */ + public async getAgreementsSpec(): Promise { + const agreementsSpec = cloneDeep(AGREEMENTS_SPEC); + + await Promise.all(map(agreementsSpec.agreements, async (agreement: any, name) => { + if (agreement.conditional) { + const option = await this.getAgreementsOption(agreement.checker, agreement.defaultOption); + agreementsSpec.agreements[name] = agreement.options[option]; + } + })); + + return agreementsSpec; + } + + private async updateAgreements( + dtoAgreements: Map = new Map(), + ): Promise { + this.logger.log('Updating application agreements.'); + const entity: AgreementsEntity = await this.agreementRepository.findOne(); + const oldAgreements = JSON.parse(entity.data); + const newValue = { + ...oldAgreements, + ...Object.fromEntries(dtoAgreements), + }; + // Detect which agreements should be defined according to the settings specification + const diff = difference( + Object.keys(AGREEMENTS_SPEC.agreements), + Object.keys(newValue), + ); + if (diff.length) { + const messages = diff.map( + (item: string) => `agreements.${item} should not be null or undefined`, + ); + throw new AgreementIsNotDefinedException(messages); + } + entity.data = JSON.stringify(newValue); + entity.version = AGREEMENTS_SPEC.version; + await this.agreementRepository.save(entity); + if (dtoAgreements.has('analytics')) { + this.analyticsService.sendAnalyticsAgreementChange( + dtoAgreements, + new Map(Object.entries({ ...oldAgreements })), + ); + } + } +} diff --git a/redisinsight/api/src/modules/core/repositories/agreements.repository.ts b/redisinsight/api/src/modules/core/repositories/agreements.repository.ts new file mode 100644 index 0000000000..fd4770a72c --- /dev/null +++ b/redisinsight/api/src/modules/core/repositories/agreements.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { AgreementsEntity } from 'src/modules/core/models/agreements.entity'; + +@EntityRepository(AgreementsEntity) +export class AgreementsRepository extends Repository {} diff --git a/redisinsight/api/src/modules/core/repositories/base/base.interface.repository.ts b/redisinsight/api/src/modules/core/repositories/base/base.interface.repository.ts new file mode 100644 index 0000000000..75fe8fb97b --- /dev/null +++ b/redisinsight/api/src/modules/core/repositories/base/base.interface.repository.ts @@ -0,0 +1,9 @@ +export interface BaseInterfaceRepository { + findAll(): Promise; + + create(data: T | any): Promise; + + findOneById(id: number | string): Promise; + + delete(id: string): Promise; +} diff --git a/redisinsight/api/src/modules/core/repositories/server.repository.ts b/redisinsight/api/src/modules/core/repositories/server.repository.ts new file mode 100644 index 0000000000..8f1be24507 --- /dev/null +++ b/redisinsight/api/src/modules/core/repositories/server.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { ServerEntity } from 'src/modules/core/models/server.entity'; + +@EntityRepository(ServerEntity) +export class ServerRepository extends Repository {} diff --git a/redisinsight/api/src/modules/core/repositories/settings.repository.ts b/redisinsight/api/src/modules/core/repositories/settings.repository.ts new file mode 100644 index 0000000000..fad833f347 --- /dev/null +++ b/redisinsight/api/src/modules/core/repositories/settings.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { SettingsEntity } from 'src/modules/core/models/settings.entity'; + +@EntityRepository(SettingsEntity) +export class SettingsRepository extends Repository {} diff --git a/redisinsight/api/src/modules/core/services/analytics/analytics.service.spec.ts b/redisinsight/api/src/modules/core/services/analytics/analytics.service.spec.ts new file mode 100644 index 0000000000..0c0d2fbed8 --- /dev/null +++ b/redisinsight/api/src/modules/core/services/analytics/analytics.service.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import { mockSettingsProvider } from 'src/__mocks__'; +import { TelemetryEvents } from 'src/constants'; +import { + AnalyticsService, + NON_TRACKING_ANONYMOUS_ID, +} from './analytics.service'; + +let mockAnalyticsTrack; +jest.mock( + 'analytics-node', + () => jest.fn() + .mockImplementation(() => ({ + track: mockAnalyticsTrack, + })), +); + +const mockAnonymousId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805'; +const mockSettingsWithPermission = { + agreements: { + version: '1.0.1', + analytics: true, + }, +}; +const mockSettingsWithoutPermission = { + agreements: { + version: '1.0.1', + analytics: false, + }, +}; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + let settingsService: ISettingsProvider; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { + provide: 'SETTINGS_PROVIDER', + useFactory: mockSettingsProvider, + }, + ], + }).compile(); + + settingsService = module.get('SETTINGS_PROVIDER'); + service = module.get(AnalyticsService); + }); + + it('should be defined', () => { + const anonymousId = service.getAnonymousId(); + + expect(service).toBeDefined(); + expect(anonymousId).toEqual(NON_TRACKING_ANONYMOUS_ID); + }); + + describe('initialize', () => { + it('should set anonymousId', () => { + service.initialize(mockAnonymousId); + + const anonymousId = service.getAnonymousId(); + + expect(anonymousId).toEqual(mockAnonymousId); + }); + }); + + describe('sendEvent', () => { + beforeEach(() => { + mockAnalyticsTrack = jest.fn(); + service.initialize(mockAnonymousId); + }); + it('should send event with anonymousId if permission are granted', async () => { + settingsService.getSettings = jest + .fn() + .mockResolvedValue(mockSettingsWithPermission); + + await service.sendEvent({ + event: TelemetryEvents.ApplicationStarted, + eventData: {}, + nonTracking: false, + }); + + expect(mockAnalyticsTrack).toHaveBeenCalledWith({ + anonymousId: mockAnonymousId, + event: TelemetryEvents.ApplicationStarted, + properties: {}, + }); + }); + it('should not send event if permission are not granted', async () => { + settingsService.getSettings = jest + .fn() + .mockResolvedValue(mockSettingsWithoutPermission); + + await service.sendEvent({ + event: 'SOME_EVENT', + eventData: {}, + nonTracking: false, + }); + + expect(mockAnalyticsTrack).not.toHaveBeenCalled(); + }); + it('should send event for non tracking events event if permission are not granted', async () => { + settingsService.getSettings = jest + .fn() + .mockResolvedValue(mockSettingsWithoutPermission); + + await service.sendEvent({ + event: TelemetryEvents.ApplicationStarted, + eventData: {}, + nonTracking: true, + }); + + expect(mockAnalyticsTrack).toHaveBeenCalledWith({ + anonymousId: NON_TRACKING_ANONYMOUS_ID, + event: TelemetryEvents.ApplicationStarted, + properties: {}, + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/services/analytics/analytics.service.ts b/redisinsight/api/src/modules/core/services/analytics/analytics.service.ts new file mode 100644 index 0000000000..5171196740 --- /dev/null +++ b/redisinsight/api/src/modules/core/services/analytics/analytics.service.ts @@ -0,0 +1,76 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { get } from 'lodash'; +import * as Analytics from 'analytics-node'; +import { AppAnalyticsEvents } from 'src/constants'; +import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface'; +import config from 'src/utils/config'; + +export const NON_TRACKING_ANONYMOUS_ID = 'UNSET'; +const ANALYTICS_CONFIG = config.get('analytics'); + +export interface ITelemetryEvent { + event: string; + eventData: Object; + nonTracking: boolean; +} + +@Injectable() +export class AnalyticsService { + private anonymousId: string = NON_TRACKING_ANONYMOUS_ID; + + private analytics; + + constructor( + @Inject('SETTINGS_PROVIDER') + private settingsService: ISettingsProvider, + ) {} + + public getAnonymousId(): string { + return this.anonymousId; + } + + @OnEvent(AppAnalyticsEvents.Initialize) + public initialize(anonymousId: string) { + this.anonymousId = anonymousId; + this.analytics = new Analytics(ANALYTICS_CONFIG.writeKey); + } + + @OnEvent(AppAnalyticsEvents.Track) + async sendEvent(payload: ITelemetryEvent) { + try { + // The event is reported only if the user's permission is granted. + // The anonymousId is also sent along with the event. + // + // The `nonTracking` argument can be set to True to mark an event that doesn't track the specific + // user in any way. When `nonTracking` is True, the event is sent regardless of whether the user's permission + // for analytics is granted or not. + // If permissions not granted anonymousId includes "UNSET" value without any user identifiers. + const { event, eventData, nonTracking } = payload; + const isAnalyticsGranted = !!get( + await this.settingsService.getSettings(), + 'agreements.analytics', + false, + ); + if (isAnalyticsGranted) { + this.analytics.track({ + anonymousId: this.anonymousId, + event, + properties: { + ...eventData, + }, + }); + } else if (nonTracking) { + this.analytics.track({ + anonymousId: NON_TRACKING_ANONYMOUS_ID, + event, + properties: { + ...eventData, + }, + }); + } + } catch (e) { + // continue regardless of error + } + } +} diff --git a/redisinsight/api/src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service.spec.ts b/redisinsight/api/src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service.spec.ts new file mode 100644 index 0000000000..b2ae7a4edb --- /dev/null +++ b/redisinsight/api/src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service.spec.ts @@ -0,0 +1,148 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { CaCertificateEntity } from 'src/modules/core/models/ca-certificate.entity'; +import { + mockCaCertDto, + mockCaCertEntity, + mockEncryptionService, + mockEncryptResult, + mockQueryBuilderGetMany, + mockRepository, + MockType, +} from 'src/__mocks__'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; +import { KeytarEncryptionErrorException } from 'src/modules/core/encryption/exceptions'; +import { CaCertBusinessService } from './ca-cert-business.service'; + +describe('CaCertBusinessService', () => { + let service: CaCertBusinessService; + let repository: MockType>; + let encryptionService: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CaCertBusinessService, + { + provide: EncryptionService, + useFactory: mockEncryptionService, + }, + { + provide: getRepositoryToken(CaCertificateEntity), + useFactory: mockRepository, + }, + ], + }).compile(); + + service = await module.get(CaCertBusinessService); + encryptionService = module.get(EncryptionService); + repository = await module.get(getRepositoryToken(CaCertificateEntity)); + }); + + describe('getAll', () => { + it('get all certificates from the repository', async () => { + mockQueryBuilderGetMany.mockResolvedValueOnce([mockCaCertEntity]); + + const result = await service.getAll(); + + expect(repository.createQueryBuilder).toHaveBeenCalled(); + expect(result).toEqual([mockCaCertEntity]); + }); + }); + + describe('getOneById', () => { + it('should successfully find entity and decrypt field', async () => { + repository.findOne.mockResolvedValue(mockCaCertEntity); + encryptionService.decrypt.mockResolvedValueOnce(mockCaCertEntity.certificate); + + const result = await service.getOneById(mockCaCertEntity.id); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: mockCaCertEntity.id }, + }); + expect(result).toEqual(mockCaCertEntity); + }); + it('should throw an error when certificate not found', async () => { + repository.findOne.mockResolvedValue(null); + + // todo: refactor. why BadRequest? + await expect(service.getOneById(mockCaCertEntity.id)).rejects.toThrow( + BadRequestException, + ); + }); + it('should find entity and return encrypted fields to equal empty string on decrypted error', async () => { + repository.findOne.mockResolvedValue(mockCaCertEntity); + encryptionService.decrypt.mockRejectedValueOnce(new Error('Decryption error')); + + const result = await service.getOneById(mockCaCertEntity.id); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: mockCaCertEntity.id }, + }); + expect(result).toEqual({ + ...mockCaCertEntity, + certificate: '', + }); + }); + }); + + describe('create', () => { + it('successfully create the certificate', async () => { + repository.findOne.mockResolvedValue(null); + repository.create.mockResolvedValueOnce(mockCaCertEntity); + encryptionService.encrypt.mockResolvedValueOnce(mockEncryptResult); + repository.save.mockResolvedValue(mockCaCertEntity); + + const result = await service.create(mockCaCertDto); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { name: mockCaCertEntity.name }, + }); + expect(repository.save).toHaveBeenCalled(); + expect(result).toEqual(mockCaCertEntity); + }); + it('certificate with this name exist', async () => { + repository.findOne.mockResolvedValue(mockCaCertEntity); + + await expect(service.create(mockCaCertDto)).rejects.toThrow( + BadRequestException, + ); + + expect(repository.save).not.toHaveBeenCalled(); + }); + it('should throw and error when unable to encrypt the data', async () => { + repository.findOne.mockResolvedValueOnce(null); + repository.create.mockResolvedValueOnce(mockCaCertEntity); + encryptionService.encrypt.mockRejectedValueOnce(new KeytarEncryptionErrorException()); + + await expect(service.create(mockCaCertDto)).rejects.toThrow( + KeytarEncryptionErrorException, + ); + + expect(repository.save).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('successfully delete the certificate', async () => { + repository.findOne.mockResolvedValue(mockCaCertEntity); + + await service.delete(mockCaCertEntity.id); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: mockCaCertEntity.id }, + }); + expect(repository.delete).toHaveBeenCalledWith(mockCaCertEntity.id); + }); + it('certificate not found', async () => { + repository.findOne.mockResolvedValue(null); + + await expect(service.delete(mockCaCertEntity.id)).rejects.toThrow( + NotFoundException, + ); + expect(repository.delete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service.ts b/redisinsight/api/src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service.ts new file mode 100644 index 0000000000..17c7aeda72 --- /dev/null +++ b/redisinsight/api/src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service.ts @@ -0,0 +1,144 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { CaCertificateEntity } from 'src/modules/core/models/ca-certificate.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CaCertDto } from 'src/modules/instances/dto/database-instance.dto'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; +import { + EncryptionServiceErrorException, +} from 'src/modules/core/encryption/exceptions'; + +@Injectable() +export class CaCertBusinessService { + private logger = new Logger('CaCertBusinessService'); + + constructor( + @InjectRepository(CaCertificateEntity) + private readonly repository: Repository, + private readonly encryptionService: EncryptionService, + ) {} + + /** + * Get list of shortened CA certificates (id, name only) + */ + async getAll(): Promise { + this.logger.log('Getting CA certificate list.'); + + return this.repository + .createQueryBuilder('c') + .select(['c.id', 'c.name']) + .getMany(); + } + + /** + * Get full CA certificate entity by id with decrypted fields + * @param id + */ + async getOneById(id: string): Promise { + this.logger.log(`Getting CA certificate with id: ${id}.`); + const entity = await this.repository.findOne({ where: { id } }); + + if (!entity) { + this.logger.error(`Unable to find CA certificate with id: ${id}`); + throw new BadRequestException(ERROR_MESSAGES.INVALID_CERTIFICATE_ID); // todo: why BadRequest? + } + + return this.decryptEntityFields(entity); + } + + async create(certDto: CaCertDto): Promise { + this.logger.log('Creating certificate.'); + const found = await this.repository.findOne({ + where: { name: certDto.name }, + }); + if (found) { + this.logger.error( + `Failed to create certificate. ${ERROR_MESSAGES.CA_CERT_EXIST}. name: ${certDto.name}`, + ); + throw new BadRequestException(ERROR_MESSAGES.CA_CERT_EXIST); + } + try { + const entity = await this.encryptEntityFields(this.repository.create({ + name: certDto.name, + certificate: certDto.cert, + })); + + return this.repository.save(entity); + } catch (error) { + this.logger.error('Failed to create certificate.', error); + + if (error instanceof EncryptionServiceErrorException) { + throw error; + } + + throw new InternalServerErrorException(); + } + } + + async delete(id: string): Promise { + this.logger.log(`Deleting certificate. id: ${id}`); + const found = await this.repository.findOne({ where: { id } }); + if (!found) { + this.logger.error(`Failed to delete certificate. Not Found. id: ${id}`); + throw new NotFoundException(); + } + try { + await this.repository.delete(id); + this.logger.log(`Succeed to delete certificate. id: ${id}`); + } catch (error) { + this.logger.error(`Failed to delete certificate ${id}`, error); + throw new InternalServerErrorException(); + } + } + + /** + * Encrypt required certificates fields based on picked encryption strategy + * Should always throw some encryption error to determine that something wrong + * with encryption strategy + * + * @param entity + * @private + */ + private async encryptEntityFields(entity: CaCertificateEntity): Promise { + const { + data: certificate, + encryption, + } = await this.encryptionService.encrypt(entity.certificate); + + return { + ...entity, + certificate, + encryption, + }; + } + + /** + * Decrypt required CA certificate fields (certificate) + * This method should not fail so in case of decryption error will return null for failed fields. + * It will cause 401 Unauthorized errors when user tries to connect to redis database + * + * @param entity + * @private + */ + private async decryptEntityFields(entity: CaCertificateEntity): Promise { + let certificate = ''; + + try { + certificate = await this.encryptionService.decrypt(entity.certificate, entity.encryption); + } catch (error) { + this.logger.error(`Unable to decrypt certificate ${entity.name}`); + } + + return { + ...entity, + certificate, + }; + } +} diff --git a/redisinsight/api/src/modules/core/services/certificates/client-cert-business/client-cert-business.service.spec.ts b/redisinsight/api/src/modules/core/services/certificates/client-cert-business/client-cert-business.service.spec.ts new file mode 100644 index 0000000000..df5fa62868 --- /dev/null +++ b/redisinsight/api/src/modules/core/services/certificates/client-cert-business/client-cert-business.service.spec.ts @@ -0,0 +1,153 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { + mockClientCertDto, + mockClientCertEntity, + mockEncryptionService, + mockEncryptResult, + mockQueryBuilderGetMany, + mockRepository, + MockType, +} from 'src/__mocks__'; +import { ClientCertificateEntity } from 'src/modules/core/models/client-certificate.entity'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; +import { KeytarEncryptionErrorException } from 'src/modules/core/encryption/exceptions'; +import { ClientCertBusinessService } from './client-cert-business.service'; + +describe('ClientCertBusinessService', () => { + let service: ClientCertBusinessService; + let repository: MockType>; + let encryptionService: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClientCertBusinessService, + { + provide: EncryptionService, + useFactory: mockEncryptionService, + }, + { + provide: getRepositoryToken(ClientCertificateEntity), + useFactory: mockRepository, + }, + ], + }).compile(); + + service = await module.get( + ClientCertBusinessService, + ); + encryptionService = module.get(EncryptionService); + repository = await module.get(getRepositoryToken(ClientCertificateEntity)); + }); + + describe('getAll', () => { + it('get all certificates from the repository', async () => { + mockQueryBuilderGetMany.mockResolvedValueOnce([mockClientCertEntity]); + + const result = await service.getAll(); + + expect(repository.createQueryBuilder).toHaveBeenCalled(); + expect(result).toEqual([mockClientCertEntity]); + }); + }); + + describe('getOneById', () => { + it('successfully found the certificate', async () => { + repository.findOne.mockResolvedValue(mockClientCertEntity); + encryptionService.decrypt + .mockResolvedValueOnce(mockClientCertEntity.certificate) + .mockResolvedValueOnce(mockClientCertEntity.key); + + const result = await service.getOneById(mockClientCertEntity.id); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: mockClientCertEntity.id }, + }); + expect(result).toEqual(mockClientCertEntity); + }); + it('certificate not found', async () => { + repository.findOne.mockResolvedValue(null); + + await expect(service.getOneById(mockClientCertEntity.id)).rejects.toThrow(BadRequestException); + }); + it('should find entity and return encrypted fields to equal empty string on decrypted error', async () => { + repository.findOne.mockResolvedValue(mockClientCertEntity); + encryptionService.decrypt + .mockRejectedValueOnce(new Error('Decryption error')) + .mockRejectedValueOnce(new Error('Decryption error')); + + const result = await service.getOneById(mockClientCertEntity.id); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: mockClientCertEntity.id }, + }); + expect(result).toEqual({ + ...mockClientCertEntity, + certificate: '', + key: '', + }); + }); + }); + + describe('create', () => { + it('successfully create the certificate', async () => { + repository.findOne.mockResolvedValue(null); + repository.create.mockResolvedValueOnce(mockClientCertEntity); + encryptionService.encrypt + .mockResolvedValueOnce(mockEncryptResult) + .mockResolvedValueOnce(mockEncryptResult); + repository.save.mockResolvedValue(mockClientCertEntity); + + const result = await service.create(mockClientCertDto); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { name: mockClientCertEntity.name }, + }); + expect(repository.save).toHaveBeenCalled(); + expect(result).toEqual(mockClientCertEntity); + }); + it('certificate with this name exist', async () => { + repository.findOne.mockResolvedValue(mockClientCertEntity); + + await expect(service.create(mockClientCertDto)).rejects.toThrow( + BadRequestException, + ); + expect(repository.save).not.toHaveBeenCalled(); + }); + it('should throw an error when unable to encrypt the data', async () => { + repository.findOne.mockResolvedValueOnce(null); + repository.create.mockResolvedValueOnce(mockClientCertEntity); + encryptionService.encrypt.mockRejectedValueOnce(new KeytarEncryptionErrorException()); + + await expect(service.create(mockClientCertDto)).rejects.toThrow( + KeytarEncryptionErrorException, + ); + + expect(repository.save).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('successfully delete the certificate', async () => { + repository.findOne.mockResolvedValue(mockClientCertEntity); + + await service.delete(mockClientCertEntity.id); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: mockClientCertEntity.id }, + }); + expect(repository.delete).toHaveBeenCalledWith(mockClientCertEntity.id); + }); + it('certificate not found', async () => { + repository.findOne.mockResolvedValue(null); + + await expect(service.delete(mockClientCertEntity.id)).rejects.toThrow( + NotFoundException, + ); + expect(repository.delete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/services/certificates/client-cert-business/client-cert-business.service.ts b/redisinsight/api/src/modules/core/services/certificates/client-cert-business/client-cert-business.service.ts new file mode 100644 index 0000000000..294caa9a8a --- /dev/null +++ b/redisinsight/api/src/modules/core/services/certificates/client-cert-business/client-cert-business.service.ts @@ -0,0 +1,166 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ClientCertificateEntity } from 'src/modules/core/models/client-certificate.entity'; +import { ClientCertPairDto } from 'src/modules/instances/dto/database-instance.dto'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; +import { + EncryptionServiceErrorException, +} from 'src/modules/core/encryption/exceptions'; + +@Injectable() +export class ClientCertBusinessService { + private logger = new Logger('ClientCertBusinessService'); + + constructor( + @InjectRepository(ClientCertificateEntity) + private readonly repository: Repository, + private readonly encryptionService: EncryptionService, + ) {} + + /** + * Get list of shortened CA certificates (id, name only) + */ + async getAll(): Promise { + this.logger.log('Getting client certificates list.'); + + return this.repository + .createQueryBuilder('c') + .select(['c.id', 'c.name']) + .getMany(); + } + + /** + * Get full Client certificate entity by id with decrypted fields + * @param id + */ + async getOneById(id: string): Promise { + this.logger.log(`Getting client certificate with id: ${id}.`); + const entity = await this.repository.findOne({ where: { id } }); + + if (!entity) { + this.logger.error(`Unable to find client certificate with id: ${id}`); + throw new BadRequestException(ERROR_MESSAGES.INVALID_CERTIFICATE_ID); // todo: why BadRequest? + } + + return this.decryptEntityFields(entity); + } + + async create(certDto: ClientCertPairDto): Promise { + this.logger.log('Creating certificate.'); + const found = await this.repository.findOne({ + where: { name: certDto.name }, + }); + + if (found) { + this.logger.error( + `Failed to create certificate. name: ${certDto.name}`, + ERROR_MESSAGES.CLIENT_CERT_EXIST, + ); + throw new BadRequestException(ERROR_MESSAGES.CLIENT_CERT_EXIST); + } + + try { + const entity = await this.encryptEntityFields(this.repository.create({ + name: certDto.name, + certificate: certDto.cert, + key: certDto.key, + })); + const res = await this.repository.save(entity); + + this.logger.log('Succeed to create certificate.'); + return res; + } catch (error) { + this.logger.error('Failed to create client certificate.', error); + + if (error instanceof EncryptionServiceErrorException) { + throw error; + } + + throw new InternalServerErrorException(); + } + } + + async delete(id: string): Promise { + this.logger.log(`Deleting client-certificate. id: ${id}`); + const found = await this.repository.findOne({ where: { id } }); + + if (!found) { + this.logger.error( + `Failed to delete client-certificate. Not Found. id: ${id}`, + ); + throw new NotFoundException(); + } + try { + await this.repository.delete(id); + this.logger.log(`Succeed to delete certificate. id: ${id}`); + return; + } catch (error) { + this.logger.error(`Failed to delete certificate ${id}`, error); + throw new InternalServerErrorException(); + } + } + + /** + * Encrypt required certificates fields based on picked encryption strategy + * Should always throw some encryption error to determine that something wrong + * with encryption strategy + * + * @param entity + * @private + */ + private async encryptEntityFields( + entity: ClientCertificateEntity, + ): Promise { + const { + data: certificate, + encryption, + } = await this.encryptionService.encrypt(entity.certificate); + + const { + data: key, + } = await this.encryptionService.encrypt(entity.key); + + return { + ...entity, + certificate, + key, + encryption, + }; + } + + /** + * Decrypt required client certificate fields (certificate and key) + * This method should not fail so in case of decryption error will return null for failed fields. + * It will cause 401 Unauthorized errors when user tries to connect to redis database + * + * @param entity + * @private + */ + private async decryptEntityFields( + entity: ClientCertificateEntity, + ): Promise { + let certificate = ''; + let key = ''; + + try { + certificate = await this.encryptionService.decrypt(entity.certificate, entity.encryption); + key = await this.encryptionService.decrypt(entity.key, entity.encryption); + } catch (error) { + this.logger.error(`Unable to decrypt client certificate ${entity.name}`); + } + + return { + ...entity, + certificate, + key, + }; + } +} diff --git a/redisinsight/api/src/modules/core/services/redis/redis.service.spec.ts b/redisinsight/api/src/modules/core/services/redis/redis.service.spec.ts new file mode 100644 index 0000000000..fbd511ca8b --- /dev/null +++ b/redisinsight/api/src/modules/core/services/redis/redis.service.spec.ts @@ -0,0 +1,441 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import * as Redis from 'ioredis-mock'; +import { v4 as uuidv4 } from 'uuid'; +import { ConnectionOptions } from 'tls'; +import { + mockCaCertDto, + mockCaCertEntity, + mockCaCertificatesService, + mockClientCertDto, + mockClientCertEntity, + mockClientCertificatesService, + mockOSSClusterDatabaseEntity, + mockSentinelDatabaseEntity, + mockStandaloneDatabaseEntity, + MockType, +} from 'src/__mocks__'; +import { AppTool, ReplyError } from 'src/models'; +import { convertEntityToDto } from 'src/modules/shared/utils/database-entity-converter'; +import { mockRedisClientInstance } from 'src/modules/shared/services/base/redis-consumer.abstract.service.spec'; +import { IFindRedisClientInstanceByOptions, RedisService } from './redis.service'; +import { CaCertBusinessService } from '../certificates/ca-cert-business/ca-cert-business.service'; +import { ClientCertBusinessService } from '../certificates/client-cert-business/client-cert-business.service'; + +jest.mock('ioredis'); + +const mockTlsConfigResult: ConnectionOptions = { + rejectUnauthorized: true, + checkServerIdentity: () => undefined, + ca: [mockCaCertDto.cert], + key: mockClientCertDto.key, + cert: mockClientCertDto.cert, +}; + +const removeNullsFromDto = (dto): any => { + const result = dto; + Object.keys(dto).forEach((key: string) => { + if (result[key] === null) { + delete result[key]; + } + }); + + return result; +}; + +describe('RedisService', () => { + let service; + let caCertBusinessService: MockType; + let clientCertBusinessService: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisService, + { + provide: CaCertBusinessService, + useFactory: mockCaCertificatesService, + }, + { + provide: ClientCertBusinessService, + useFactory: mockClientCertificatesService, + }, + ], + }).compile(); + + service = await module.get(RedisService); + caCertBusinessService = module.get(CaCertBusinessService); + clientCertBusinessService = module.get(ClientCertBusinessService); + }); + + it('should be defined', () => { + expect(service.clients).toEqual([]); + }); + + describe('connectToDatabaseInstance', () => { + beforeEach(async () => { + service.clients = []; + }); + it('should create standalone client', async () => { + const mockClient = new Redis(); + const dto = convertEntityToDto(mockStandaloneDatabaseEntity); + service.createStandaloneClient = jest.fn().mockResolvedValue(mockClient); + + const result = await service.connectToDatabaseInstance(dto); + + expect(result).toEqual(mockClient); + expect(service.createStandaloneClient).toHaveBeenCalledWith(dto, AppTool.Common, true, undefined); + }); + it('should create cluster client', async () => { + const mockClient = new Redis.Cluster([ + 'redis://localhost:7001', + 'redis://localhost:7002', + ]); + const dto = removeNullsFromDto(convertEntityToDto(mockOSSClusterDatabaseEntity)); + + const { endpoints, connectionType, ...options } = dto; + service.createClusterClient = jest.fn().mockResolvedValue(mockClient); + + const result = await service.connectToDatabaseInstance(dto); + + expect(result).toEqual(mockClient); + expect(service.createClusterClient).toHaveBeenCalledWith( + options, + endpoints, + true, + undefined, + ); + }); + it('should create sentinel client', async () => { + const mockClient = new Redis(); + const dto = removeNullsFromDto(convertEntityToDto(mockSentinelDatabaseEntity)); + Object.keys(dto).forEach((key: string) => { + if (dto[key] === null) { + delete dto[key]; + } + }); + const { endpoints, connectionType, ...options } = dto; + service.createSentinelClient = jest.fn().mockResolvedValue(mockClient); + + const result = await service.connectToDatabaseInstance(dto); + + expect(result).toEqual(mockClient); + expect(service.createSentinelClient).toHaveBeenCalledWith( + options, + endpoints, + AppTool.Common, + true, + undefined, + ); + }); + it('should select redis database by number', async () => { + const mockClient = new Redis(); + mockClient.send_command = jest.fn(); + const dto = convertEntityToDto(mockStandaloneDatabaseEntity); + service.createStandaloneClient = jest.fn().mockResolvedValue(mockClient); + + await service.connectToDatabaseInstance(dto, AppTool.Common); + + expect(service.createStandaloneClient).toHaveBeenCalledWith(dto, AppTool.Common, true, undefined); + }); + it('should throw error db index is out of range', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + message: '(error) DB index is out of range', + command: 'SELECT', + }; + service.createStandaloneClient = jest.fn().mockRejectedValue(replyError); + + try { + await service.connectToDatabaseInstance( + convertEntityToDto(mockStandaloneDatabaseEntity), + ); + fail('Should throw an error'); + } catch (err) { + expect(err).toEqual(replyError); + } + expect(service.clients.length).toEqual(0); + }); + it('connection error [Connection details are incorrect]', async () => { + service.createStandaloneClient = jest + .fn() + .mockRejectedValue(new Error('ENOTFOUND some message')); + + try { + await service.connectToDatabaseInstance( + convertEntityToDto(mockStandaloneDatabaseEntity), + 0, + ); + fail('Should throw an error'); + } catch (err) { + expect(err.message).toEqual('ENOTFOUND some message'); + expect(service.clients.length).toEqual(0); + } + }); + }); + + describe('getClientInstance', () => { + beforeEach(() => { + service.clients = [ + { + ...mockRedisClientInstance, tool: AppTool.Common, + }, + { + ...mockRedisClientInstance, tool: AppTool.Browser, + }, + { + ...mockRedisClientInstance, tool: AppTool.CLI, + }, + ]; + }); + it('should correctly find client instance for App.Common by instance id', () => { + const newClient = { ...service.clients[0], tool: AppTool.Browser }; + service.clients.push(newClient); + const options = { + instanceId: newClient.instanceId, + }; + + const result = service.getClientInstance(options); + + expect(result).toEqual(service.clients[0]); + }); + it('should correctly find client instance by instance id and tool', () => { + const options: IFindRedisClientInstanceByOptions = { + instanceId: service.clients[0].instanceId, + tool: AppTool.CLI, + }; + + const result = service.getClientInstance(options); + + expect(result).toEqual(service.clients[2]); + }); + it('should correctly find client instance by instance id, tool and uuid', () => { + const newClient = { ...mockRedisClientInstance, uuid: uuidv4(), tool: AppTool.CLI }; + service.clients.push(newClient); + const options: IFindRedisClientInstanceByOptions = { + instanceId: newClient.instanceId, + uuid: newClient.uuid, + tool: newClient.tool, + }; + + const result = service.getClientInstance(options); + + expect(result).toEqual(newClient); + }); + it('should return undefined', () => { + const options: IFindRedisClientInstanceByOptions = { + instanceId: 'invalid-instance-id', + }; + + const result = service.getClientInstance(options); + + expect(result).toBeUndefined(); + }); + }); + + describe('removeClientInstance', () => { + beforeEach(() => { + service.clients = [ + { + ...mockRedisClientInstance, + tool: AppTool.Common, + }, + { + ...mockRedisClientInstance, + tool: AppTool.Browser, + }, + ]; + }); + it('should remove only client for browser tool', () => { + const options: IFindRedisClientInstanceByOptions = { + instanceId: mockRedisClientInstance.instanceId, + tool: AppTool.Browser, + }; + + const result = service.removeClientInstance(options); + + expect(result).toEqual(1); + expect(service.clients.length).toEqual(1); + }); + it('should remove all clients by instance id', () => { + const options: IFindRedisClientInstanceByOptions = { + instanceId: mockRedisClientInstance.instanceId, + }; + + const result = service.removeClientInstance(options); + + expect(result).toEqual(2); + expect(service.clients.length).toEqual(0); + }); + }); + + describe('setClientInstance', () => { + beforeEach(() => { + service.clients = [{ ...mockRedisClientInstance }]; + }); + it('should add new client', () => { + const initialClientsCount = service.clients.length; + const newClientInstance = { + ...mockRedisClientInstance, + instanceId: uuidv4(), + }; + + const result = service.setClientInstance(newClientInstance); + + expect(result).toBe(1); + expect(service.clients.length).toBe(initialClientsCount + 1); + }); + it('should replace exist client', () => { + const initialClientsCount = service.clients.length; + + const result = service.setClientInstance(mockRedisClientInstance); + + expect(result).toBe(0); + expect(service.clients.length).toBe(initialClientsCount); + }); + }); + + describe('isClientConnected', () => { + const mockClient = new Redis(); + it('should return true', async () => { + mockClient.status = 'ready'; + + const result = service.isClientConnected(mockClient); + + expect(result).toEqual(true); + }); + it('should return false', async () => { + mockClient.status = 'end'; + + const result = service.isClientConnected(mockClient); + + expect(result).toEqual(false); + }); + }); + + describe('getRedisConnectionConfig', () => { + it('should return config with tls', async () => { + service.getTLSConfig = jest.fn().mockResolvedValue(mockTlsConfigResult); + const dto = convertEntityToDto(mockStandaloneDatabaseEntity); + const { + host, + port, + password, + username, + } = dto; + + const mockResult = { + host, + port, + username, + password, + tls: mockTlsConfigResult, + }; + + const result = await service.getRedisConnectionConfig(dto); + + expect(JSON.stringify(result)).toEqual(JSON.stringify(mockResult)); + }); + it('should return without tls', async () => { + const dto = convertEntityToDto(mockStandaloneDatabaseEntity); + delete dto.tls; + const { + host, + port, + password, + username, + } = dto; + + const mockResult = { + host, + port, + username, + password, + }; + + const result = await service.getRedisConnectionConfig(dto); + + expect(result).toEqual(mockResult); + }); + }); + + describe('getTLSConfig', () => { + it('should return tls config', async () => { + service.getCaCertConfig = jest + .fn() + .mockResolvedValue({ ca: [mockCaCertDto.cert] }); + service.getClientCertConfig = jest.fn().mockResolvedValue({ + key: mockClientCertDto.key, + cert: mockClientCertDto.cert, + }); + const { tls } = convertEntityToDto(mockStandaloneDatabaseEntity); + + const result = await service.getTLSConfig(tls); + + expect(JSON.stringify(result)).toEqual( + JSON.stringify(mockTlsConfigResult), + ); + }); + }); + + describe('getCaCertConfig', () => { + it('should load exist cert', async () => { + caCertBusinessService.getOneById = jest + .fn() + .mockResolvedValue(mockCaCertEntity); + const { tls } = convertEntityToDto(mockStandaloneDatabaseEntity); + + const result = await service.getCaCertConfig(tls); + + expect(result).toEqual({ ca: [mockCaCertDto.cert] }); + expect(caCertBusinessService.getOneById).toHaveBeenCalledWith( + tls.caCertId, + ); + }); + it('should return new cert', async () => { + const result = await service.getCaCertConfig({ + newCaCert: mockCaCertDto, + }); + + expect(result).toEqual({ ca: [mockCaCertDto.cert] }); + expect(caCertBusinessService.getOneById).not.toHaveBeenCalled(); + }); + it('should return null', async () => { + const result = await service.getCaCertConfig({}); + + expect(result).toBeNull(); + }); + }); + + describe('getClientCertConfig', () => { + const mockResult = { + key: mockClientCertDto.key, + cert: mockClientCertDto.cert, + }; + it('should load exist cert', async () => { + clientCertBusinessService.getOneById = jest + .fn() + .mockResolvedValue(mockClientCertEntity); + const { tls } = convertEntityToDto(mockStandaloneDatabaseEntity); + + const result = await service.getClientCertConfig(tls); + + expect(result).toEqual(mockResult); + expect(clientCertBusinessService.getOneById).toHaveBeenCalledWith( + tls.clientCertPairId, + ); + }); + it('should return new cert', async () => { + const result = await service.getClientCertConfig({ + newClientCertPair: mockClientCertDto, + }); + + expect(result).toEqual(mockResult); + expect(clientCertBusinessService.getOneById).not.toHaveBeenCalled(); + }); + it('should return null', async () => { + const result = await service.getClientCertConfig({}); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/services/redis/redis.service.ts b/redisinsight/api/src/modules/core/services/redis/redis.service.ts new file mode 100644 index 0000000000..9c9198746b --- /dev/null +++ b/redisinsight/api/src/modules/core/services/redis/redis.service.ts @@ -0,0 +1,381 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConnectionOptions, SecureContextOptions } from 'tls'; +import * as Redis from 'ioredis'; +import IORedis, { RedisOptions } from 'ioredis'; +import { v4 as uuidv4 } from 'uuid'; +import { + find, + findIndex, + isNil, + omitBy, + remove, +} from 'lodash'; +import { AppTool } from 'src/models'; +import apiConfig from 'src/utils/config'; +import { CONNECTION_NAME_GLOBAL_PREFIX } from 'src/constants'; +import { + ConnectionOptionsDto, + DatabaseInstanceResponse, + TlsDto, +} from 'src/modules/instances/dto/database-instance.dto'; +import { IRedisClusterNodeAddress } from 'src/models/redis-cluster'; +import { ConnectionType } from 'src/modules/core/models/database-instance.entity'; +import { CaCertBusinessService } from '../certificates/ca-cert-business/ca-cert-business.service'; +import { ClientCertBusinessService } from '../certificates/client-cert-business/client-cert-business.service'; + +const REDIS_CLIENTS_CONFIG = apiConfig.get('redis_clients'); + +export interface ISetClientInstanceOptions { + instanceId: string; + tool: AppTool; + uuid: string; +} + +export interface IRedisClientInstance { + instanceId: string; + tool: AppTool; + client: any; + uuid: string; + lastTimeUsed: number; +} + +export interface IFindRedisClientInstanceByOptions { + instanceId: string; + tool?: AppTool; + uuid?: string; +} + +@Injectable() +export class RedisService { + private logger = new Logger('RedisService'); + + private lastClientsSync: number; + + public clients: IRedisClientInstance[] = []; + + constructor( + private caCertBusinessService: CaCertBusinessService, + private clientCertBusinessService: ClientCertBusinessService, + ) { + this.lastClientsSync = Date.now(); + } + + public async createStandaloneClient( + options: ConnectionOptionsDto, + appTool: AppTool, + useRetry: boolean, + connectionName: string = CONNECTION_NAME_GLOBAL_PREFIX, + ): Promise { + const config = await this.getRedisConnectionConfig(options); + + // Connect to particular logical database for browser clients only + if ([AppTool.Browser, AppTool.Common].includes(appTool)) { + config.db = options.db; + } + + return new Promise((resolve, reject) => { + try { + const client = new Redis({ + ...config, + showFriendlyErrorStack: true, + maxRetriesPerRequest: REDIS_CLIENTS_CONFIG.maxRetriesPerRequest, + connectionName, + retryStrategy: useRetry ? this.retryStrategy : () => undefined, + }); + client.on('error', (e): void => { + this.logger.error('Failed connection to the redis database.', e); + reject(e); + }); + client.on('ready', (): void => { + this.logger.log('Successfully connected to the redis database'); + resolve(client); + }); + client.on('reconnecting', (): void => { + this.logger.log('Reconnecting to the redis database'); + }); + } catch (e) { + reject(e); + } + }); + } + + public async createClusterClient( + options: ConnectionOptionsDto, + nodes: IRedisClusterNodeAddress[], + useRetry: boolean = false, + connectionName: string = CONNECTION_NAME_GLOBAL_PREFIX, + ): Promise { + const config = await this.getRedisConnectionConfig(options); + return new Promise((resolve, reject) => { + try { + const cluster = new Redis.Cluster(nodes, { + clusterRetryStrategy: useRetry ? this.retryStrategy : () => undefined, + redisOptions: { + ...config, + showFriendlyErrorStack: true, + maxRetriesPerRequest: REDIS_CLIENTS_CONFIG.maxRetriesPerRequest, + connectionName, + retryStrategy: useRetry ? this.retryStrategy : () => undefined, + }, + }); + cluster.on('error', (e): void => { + this.logger.error('Failed connection to the redis oss cluster', e); + reject(e); + }); + cluster.on('ready', (): void => { + this.logger.log('Successfully connected to the redis oss cluster.'); + resolve(cluster); + }); + } catch (e) { + reject(e); + } + }); + } + + public async createSentinelClient( + options: ConnectionOptionsDto, + sentinels: Array<{ host: string; port: number }>, + appTool: AppTool, + useRetry: boolean = false, + connectionName: string = CONNECTION_NAME_GLOBAL_PREFIX, + ): Promise { + const { + username, password, sentinelMaster, tls, + } = options; + const config: RedisOptions = { + sentinels, + name: sentinelMaster.name, + sentinelUsername: username, + sentinelPassword: password, + username: sentinelMaster?.username, + password: sentinelMaster?.password, + }; + + // Connect to particular logical database for browser clients only + if ([AppTool.Browser, AppTool.Common].includes(appTool)) { + config.db = options.db; + } + + if (tls) { + const tlsConfig = await this.getTLSConfig(tls); + config.tls = tlsConfig; + config.sentinelTLS = tlsConfig; + config.enableTLSForSentinelMode = true; + } + return new Promise((resolve, reject) => { + try { + const client = new Redis({ + ...config, + showFriendlyErrorStack: true, + maxRetriesPerRequest: REDIS_CLIENTS_CONFIG.maxRetriesPerRequest, + connectionName, + retryStrategy: useRetry ? this.retryStrategy : () => undefined, + sentinelRetryStrategy: useRetry + ? this.retryStrategy + : () => undefined, + }); + client.on('error', (e): void => { + this.logger.error('Failed connection to the redis oss sentinel', e); + reject(e); + }); + client.on('ready', (): void => { + this.logger.log('Successfully connected to the redis oss sentinel.'); + resolve(client); + }); + } catch (e) { + reject(e); + } + }); + } + + public async connectToDatabaseInstance( + databaseDto: DatabaseInstanceResponse, + tool = AppTool.Common, + connectionName?, + ): Promise { + const database = databaseDto; + Object.keys(database).forEach((key: string) => { + if (database[key] === null) { + delete database[key]; + } + }); + let client; + const { endpoints, connectionType, ...options } = database; + switch (connectionType) { + case ConnectionType.STANDALONE: + client = await this.createStandaloneClient(database, tool, true, connectionName); + break; + case ConnectionType.CLUSTER: + client = await this.createClusterClient(options, endpoints, true, connectionName); + break; + case ConnectionType.SENTINEL: + client = await this.createSentinelClient(options, endpoints, tool, true, connectionName); + break; + default: + client = await this.createStandaloneClient(database, tool, true, connectionName); + } + + return client; + } + + public isClientConnected(client: IORedis.Redis | IORedis.Cluster): boolean { + try { + return client.status === 'ready'; + } catch (e) { + return false; + } + } + + public getClientInstance( + options: IFindRedisClientInstanceByOptions, + ): IRedisClientInstance { + const found = this.findClientInstance(options.instanceId, options.tool, options.uuid); + if (found) { + found.lastTimeUsed = Date.now(); + } + this.syncClients(); + return found; + } + + public removeClientInstance( + options: IFindRedisClientInstanceByOptions, + ): number { + const removed: IRedisClientInstance[] = remove( + this.clients, + options, + ); + removed.forEach((clientInstance) => { + clientInstance.client.disconnect(); + }); + return removed.length; + } + + public setClientInstance(options: ISetClientInstanceOptions, client): 0 | 1 { + const found = this.findClientInstance(options.instanceId, options.tool, options.uuid); + if (found) { + const index = findIndex(this.clients, { uuid: found.uuid }); + this.clients[index].client.disconnect(); + this.clients[index] = { + ...this.clients[index], + lastTimeUsed: Date.now(), + client, + }; + return 0; + } + const clientInstance: IRedisClientInstance = { + ...options, + uuid: options.uuid || uuidv4(), + lastTimeUsed: Date.now(), + client, + }; + this.clients.push(clientInstance); + return 1; + } + + private syncClients() { + const currentTime = Date.now(); + const syncDif = currentTime - this.lastClientsSync; + if (syncDif >= REDIS_CLIENTS_CONFIG.idleSyncInterval) { + this.lastClientsSync = currentTime; + this.clients = this.clients.filter((item) => { + const idle = Date.now() - item.lastTimeUsed; + if (idle >= REDIS_CLIENTS_CONFIG.maxIdleThreshold) { + item.client.disconnect(); + return false; + } + return true; + }); + } + } + + private async getRedisConnectionConfig( + options: ConnectionOptionsDto, + ): Promise { + const { + host, port, password, username, tls, + } = options; + const config: IORedis.RedisOptions = { + host, port, username, password, + }; + if (tls) { + config.tls = await this.getTLSConfig(tls); + } + return config; + } + + private async getTLSConfig(tls: TlsDto): Promise { + let config: ConnectionOptions; + config = { + rejectUnauthorized: tls.verifyServerCert, + checkServerIdentity: () => undefined, + }; + if (tls.caCertId || tls.newCaCert) { + const caCertConfig = await this.getCaCertConfig(tls); + config = { + ...config, + ...caCertConfig, + }; + } + if (tls.clientCertPairId || tls.newClientCertPair) { + const clientCertConfig = await this.getClientCertConfig(tls); + config = { + ...config, + ...clientCertConfig, + }; + } + return config; + } + + private async getCaCertConfig(tlsDto: TlsDto): Promise { + if (tlsDto.caCertId) { + const caCertificateEntity = await this.caCertBusinessService.getOneById(tlsDto.caCertId); + return { + ca: [caCertificateEntity.certificate], + }; + } + if (tlsDto.newCaCert) { + return { + ca: [tlsDto.newCaCert.cert], + }; + } + return null; + } + + private async getClientCertConfig( + tlsDto: TlsDto, + ): Promise { + if (tlsDto.clientCertPairId) { + const clientCertificateEntity = await this.clientCertBusinessService.getOneById( + tlsDto.clientCertPairId, + ); + + return { + cert: clientCertificateEntity.certificate, + key: clientCertificateEntity.key, + }; + } + if (tlsDto.newClientCertPair) { + return { + key: tlsDto.newClientCertPair.key, + cert: tlsDto.newClientCertPair.cert, + }; + } + return null; + } + + private retryStrategy(times: number): number { + if (times < REDIS_CLIENTS_CONFIG.retryTimes) { + return Math.min(times * REDIS_CLIENTS_CONFIG.retryDelay, 2000); + } + return undefined; + } + + private findClientInstance( + instanceId: string, + tool: AppTool = AppTool.Common, + uuid: string = undefined, + ): IRedisClientInstance { + const options = omitBy({ instanceId, uuid, tool }, isNil); + return find(this.clients, options); + } +} diff --git a/redisinsight/api/src/modules/core/services/settings-analytics/settings-analytics.service.spec.ts b/redisinsight/api/src/modules/core/services/settings-analytics/settings-analytics.service.spec.ts new file mode 100644 index 0000000000..09a37fda22 --- /dev/null +++ b/redisinsight/api/src/modules/core/services/settings-analytics/settings-analytics.service.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AppAnalyticsEvents, TelemetryEvents } from 'src/constants'; +import { GetAppSettingsResponse } from 'src/dto/settings.dto'; +import { SettingsAnalyticsService } from './settings-analytics.service'; + +describe('SettingsAnalyticsService', () => { + let service: SettingsAnalyticsService; + let eventEmitter: EventEmitter2; + let sendEventMethod; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + SettingsAnalyticsService, + ], + }).compile(); + + service = await module.get(SettingsAnalyticsService); + eventEmitter = await module.get(EventEmitter2); + eventEmitter.emit = jest.fn(); + sendEventMethod = jest.spyOn( + service, + 'sendEvent', + ); + }); + + describe('sendAnalyticsAgreementChange', () => { + it('should emit ANALYTICS_PERMISSION with state enabled on first app launch', async () => { + await service.sendAnalyticsAgreementChange( + new Map([['analytics', true]]), + undefined, + ); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.AnalyticsPermission, + eventData: { state: 'enabled' }, + nonTracking: true, + }); + }); + it('should emit ANALYTICS_PERMISSION with state disabled on first app launch', async () => { + await service.sendAnalyticsAgreementChange( + new Map([['analytics', false]]), + undefined, + ); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.AnalyticsPermission, + eventData: { state: 'disabled' }, + nonTracking: true, + }); + }); + it('should not emit ANALYTICS_PERMISSION if agreement did not changed', async () => { + await service.sendAnalyticsAgreementChange( + new Map([['analytics', false]]), + new Map([['analytics', false]]), + ); + + expect(eventEmitter.emit).not.toHaveBeenCalledWith( + AppAnalyticsEvents.Track, + { + event: TelemetryEvents.AnalyticsPermission, + eventData: expect.anything(), + nonTracking: true, + }, + ); + }); + it('should emit [ANALYTICS_PERMISSION] if agreement changed', async () => { + await service.sendAnalyticsAgreementChange( + new Map([['analytics', false]]), + new Map([['analytics', true]]), + ); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.AnalyticsPermission, + eventData: { state: 'disabled' }, + nonTracking: true, + }); + }); + }); + + describe('sendSettingsUpdatedEvent', () => { + const defaultSettings: GetAppSettingsResponse = { + agreements: null, + scanThreshold: 10000, + theme: null, + }; + it('should emit [SETTINGS_KEYS_TO_SCAN_CHANGED] event', async () => { + await service.sendSettingsUpdatedEvent( + { ...defaultSettings, scanThreshold: 100000 }, + { ...defaultSettings, scanThreshold: 10000 }, + ); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.SettingsScanThresholdChanged, + { + currentValue: 100000, + currentValueRange: '50 001 - 100 000', + previousValue: 10000, + previousValueRange: '5 001 - 10 000', + }, + ); + }); + it('should not emit [SETTINGS_KEYS_TO_SCAN_CHANGED] for the same value', async () => { + await service.sendSettingsUpdatedEvent( + { ...defaultSettings, scanThreshold: 10000 }, + { ...defaultSettings, scanThreshold: 10000 }, + ); + + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + it('should not emit event on error', async () => { + await service.sendSettingsUpdatedEvent( + { ...defaultSettings, scanThreshold: 10000 }, + undefined, + ); + + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/core/services/settings-analytics/settings-analytics.service.ts b/redisinsight/api/src/modules/core/services/settings-analytics/settings-analytics.service.ts new file mode 100644 index 0000000000..c683d0802f --- /dev/null +++ b/redisinsight/api/src/modules/core/services/settings-analytics/settings-analytics.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + differenceWith, + isEqual, + has, +} from 'lodash'; +import { AppAnalyticsEvents, TelemetryEvents } from 'src/constants'; +import { getRangeForNumber, SCAN_THRESHOLD_BREAKPOINTS } from 'src/utils'; +import { GetAppSettingsResponse } from 'src/dto/settings.dto'; +import { TelemetryBaseService } from 'src/modules/shared/services/base/telemetry.base.service'; + +@Injectable() +export class SettingsAnalyticsService extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + // eslint-disable-next-line class-methods-use-this,max-len + sendSettingsUpdatedEvent( + newSettings: GetAppSettingsResponse, + oldSettings: GetAppSettingsResponse, + ): void { + try { + const dif = Object.fromEntries( + differenceWith(Object.entries(newSettings), Object.entries(oldSettings), isEqual), + ); + if (has(dif, 'scanThreshold')) { + this.sendScanThresholdChanged(dif.scanThreshold, oldSettings.scanThreshold); + } + } catch (e) { + // continue regardless of error + } + } + + // Detect that analytics agreement was first established or changed + sendAnalyticsAgreementChange( + newAgreements: Map, + oldAgreements: Map = new Map(), + ) { + try { + const newPermission = newAgreements.get('analytics'); + const oldPermission = oldAgreements.get('analytics'); + if (oldPermission !== newPermission) { + this.eventEmitter.emit(AppAnalyticsEvents.Track, { + event: TelemetryEvents.AnalyticsPermission, + eventData: { + state: newPermission ? 'enabled' : 'disabled', + }, + nonTracking: true, + }); + } + } catch (e) { + // continue regardless of error + } + } + + private sendScanThresholdChanged(currentValue: number, previousValue: number): void { + this.sendEvent( + TelemetryEvents.SettingsScanThresholdChanged, + { + currentValue, + currentValueRange: getRangeForNumber(currentValue, SCAN_THRESHOLD_BREAKPOINTS), + previousValue, + previousValueRange: getRangeForNumber(previousValue, SCAN_THRESHOLD_BREAKPOINTS), + }, + ); + } +} diff --git a/redisinsight/api/src/modules/instances/controllers/certificates/certificates.controller.ts b/redisinsight/api/src/modules/instances/controllers/certificates/certificates.controller.ts new file mode 100644 index 0000000000..15f7036808 --- /dev/null +++ b/redisinsight/api/src/modules/instances/controllers/certificates/certificates.controller.ts @@ -0,0 +1,69 @@ +import { + ClassSerializerInterceptor, + Controller, + Delete, + Get, + Param, + UseInterceptors, +} from '@nestjs/common'; +import { + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { CaCertificateEntity } from 'src/modules/core/models/ca-certificate.entity'; +import { ClientCertificateEntity } from 'src/modules/core/models/client-certificate.entity'; +import { + CaCertBusinessService, +} from 'src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service'; +import { + ClientCertBusinessService, +} from 'src/modules/core/services/certificates/client-cert-business/client-cert-business.service'; + +@ApiTags('TLS Certificates') +@Controller('certificates') +export class CertificatesController { + constructor( + private caCertBusinessService: CaCertBusinessService, + private clientCertBusinessService: ClientCertBusinessService, + ) {} + + @UseInterceptors(ClassSerializerInterceptor) + @Get('ca') + @ApiOperation({ description: 'Get Ca Certificate list' }) + @ApiOkResponse({ + description: 'Ca Certificate list', + isArray: true, + type: CaCertificateEntity, + }) + async getCaCertList(): Promise { + return await this.caCertBusinessService.getAll(); + } + + @Delete('ca/:id') + @ApiOperation({ description: 'Delete Ca Certificate by id' }) + @ApiParam({ name: 'id', type: String }) + async deleteCaCert(@Param('id') id: string): Promise { + await this.caCertBusinessService.delete(id); + } + + @UseInterceptors(ClassSerializerInterceptor) + @Get('client') + @ApiOperation({ description: 'Get Client Certificate list' }) + @ApiOkResponse({ + description: 'Client Certificate list', + isArray: true, + type: ClientCertificateEntity, + }) + async getClientCertList(): Promise { + return await this.clientCertBusinessService.getAll(); + } + + @Delete('client/:id') + @ApiOperation({ description: 'Delete Client Certificate pair by id' }) + @ApiParam({ name: 'id', type: String }) + async deleteClientCertificatePair(@Param('id') id: string): Promise { + await this.clientCertBusinessService.delete(id); + } +} diff --git a/redisinsight/api/src/modules/instances/controllers/instances/instances.controller.ts b/redisinsight/api/src/modules/instances/controllers/instances/instances.controller.ts new file mode 100644 index 0000000000..c0d7f0b05f --- /dev/null +++ b/redisinsight/api/src/modules/instances/controllers/instances/instances.controller.ts @@ -0,0 +1,354 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, + Res, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBody, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { AppTool } from 'src/models'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { TimeoutInterceptor } from 'src/modules/core/interceptors/timeout.interceptor'; +import { + AddSentinelMasterResponse, + AddSentinelMastersDto, +} from 'src/modules/instances/dto/redis-sentinel.dto'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { DatabaseOverview } from 'src/modules/instances/dto/database-overview.dto'; +import { + AddDatabaseInstanceDto, + DatabaseInstanceResponse, + DeleteDatabaseInstanceDto, + DeleteDatabaseInstanceResponse, + RenameDatabaseInstanceDto, + RenameDatabaseInstanceResponse, +} from '../../dto/database-instance.dto'; +import { + AddRedisDatabaseStatus, + AddRedisEnterpriseDatabaseResponse, + AddRedisEnterpriseDatabasesDto, +} from '../../dto/redis-enterprise-cluster.dto'; +import { + AddMultipleRedisCloudDatabasesDto, + AddRedisCloudDatabaseResponse, +} from '../../dto/redis-enterprise-cloud.dto'; +import { RedisDatabaseInfoResponse } from '../../dto/redis-info.dto'; + +@ApiTags('Database Instances') +@Controller('') +export class InstancesController { + constructor(private instancesBusinessService: InstancesBusinessService) {} + + @UseInterceptors(ClassSerializerInterceptor) + @Get('') + @ApiEndpoint({ + statusCode: 200, + description: 'Get database instance list', + responses: [ + { + status: 200, + description: 'Database instance list', + isArray: true, + type: DatabaseInstanceResponse, + }, + ], + }) + async getAll(): Promise { + return this.instancesBusinessService.getAll(); + } + + @UseInterceptors(ClassSerializerInterceptor) + @Get('/:id') + @ApiEndpoint({ + statusCode: 200, + description: 'Get database instance by id', + responses: [ + { + status: 200, + description: 'Database instance', + type: DatabaseInstanceResponse, + }, + ], + }) + async getOneById( + @Param('id') id: string, + ): Promise { + return await this.instancesBusinessService.getOneById(id); + } + + @UseInterceptors(ClassSerializerInterceptor) + @UseInterceptors(new TimeoutInterceptor(ERROR_MESSAGES.CONNECTION_TIMEOUT)) + @Post('') + @ApiOperation({ description: 'Add database instance' }) + @ApiBody({ type: AddDatabaseInstanceDto }) + @ApiOkResponse({ + description: 'Created database instance', + type: DatabaseInstanceResponse, + }) + @UsePipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ) + async addDatabase( + @Body() addInstanceDto: AddDatabaseInstanceDto, + ): Promise { + return await this.instancesBusinessService.addDatabase(addInstanceDto); + } + + @UseInterceptors(ClassSerializerInterceptor) + @UseInterceptors(new TimeoutInterceptor(ERROR_MESSAGES.CONNECTION_TIMEOUT)) + @Put(':id') + @ApiOperation({ description: 'Update database instance by id' }) + @ApiBody({ type: AddDatabaseInstanceDto }) + @ApiParam({ name: 'id', type: String }) + @ApiOkResponse({ + description: 'Updated database instance', + type: DatabaseInstanceResponse, + }) + @UsePipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ) + async updateDatabaseInstance( + @Param('id') id: string, + @Body() database: AddDatabaseInstanceDto, + ): Promise { + return await this.instancesBusinessService.update(id, database); + } + + @Patch(':id/name') + @ApiEndpoint({ + statusCode: 200, + description: 'Rename database instance by id', + responses: [ + { + status: 200, + description: 'New database instance name', + type: RenameDatabaseInstanceResponse, + }, + ], + }) + @UsePipes(new ValidationPipe({ transform: true })) + async renameDatabaseInstance( + @Param('id') id: string, + @Body() dto: RenameDatabaseInstanceDto, + ): Promise { + return await this.instancesBusinessService.rename(id, dto.newName); + } + + @Delete('/:id') + @ApiOperation({ description: 'Delete database instance by id' }) + @ApiParam({ name: 'id', type: String }) + async deleteDatabaseInstance(@Param('id') id: string): Promise { + await this.instancesBusinessService.delete(id); + } + + @Delete('') + @ApiOperation({ description: 'Delete many database instances by ids' }) + @ApiBody({ type: DeleteDatabaseInstanceDto }) + @UsePipes(new ValidationPipe({ transform: true })) + async bulkDeleteDatabaseInstance( + @Body() dto: DeleteDatabaseInstanceDto, + ): Promise { + return await this.instancesBusinessService.bulkDelete(dto.ids); + } + + @Get(':id/connect') + @UseInterceptors(new TimeoutInterceptor()) + @ApiEndpoint({ + description: 'Connect to database instance by id', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Successfully connected to database instance', + }, + ], + }) + @UsePipes(new ValidationPipe({ transform: true })) + async connectToDatabaseInstance( + @Param('id') id: string, + ): Promise { + await this.instancesBusinessService.connectToInstance( + id, + AppTool.Common, + true, + ); + } + + @Get(':id/info') + @UseInterceptors(new TimeoutInterceptor()) + @ApiEndpoint({ + description: 'Get Redis database config info', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Redis database info', + type: RedisDatabaseInfoResponse, + }, + ], + }) + async getDatabaseInfo( + @Param('id') id: string, + ): Promise { + return this.instancesBusinessService.getInfo( + id, + AppTool.Common, + true, + ); + } + + @Get(':id/overview') + @UseInterceptors(new TimeoutInterceptor()) + @ApiEndpoint({ + description: 'Get Redis database overview', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Redis database overview', + type: DatabaseOverview, + }, + ], + }) + async getDatabaseOverview( + @Param('id') id: string, + ): Promise { + return this.instancesBusinessService.getOverview(id); + } + + @Get(':id/plugin-commands') + @UseInterceptors(new TimeoutInterceptor()) + @ApiEndpoint({ + description: 'Get Redis Commands available for plugins', + statusCode: 200, + responses: [ + { + status: 200, + description: 'List of available commands', + type: [String], + }, + ], + }) + async getPluginCommands( + @Param('id') id: string, + ): Promise { + return this.instancesBusinessService.getPluginCommands(id); + } + + @Post('redis-enterprise-dbs') + @ApiEndpoint({ + description: 'Add databases from Redis Enterprise cluster', + statusCode: 201, + responses: [ + { + status: 201, + description: 'Added databases list.', + type: AddRedisEnterpriseDatabaseResponse, + isArray: true, + }, + ], + }) + @UsePipes(new ValidationPipe({ transform: true })) + async addRedisEnterpriseDatabases( + @Body() dto: AddRedisEnterpriseDatabasesDto, + @Res() res: Response, + ): Promise { + const { uids, ...connectionDetails } = dto; + const result = await this.instancesBusinessService.addRedisEnterpriseDatabases( + connectionDetails, + uids, + ); + const hasSuccessResult = result.some( + (addResponse: AddRedisEnterpriseDatabaseResponse) => addResponse.status === AddRedisDatabaseStatus.Success, + ); + if (!hasSuccessResult) { + return res.status(200).json(result); + } + return res.json(result); + } + + @Post('redis-cloud-dbs') + @ApiEndpoint({ + description: 'Add databases from Redis Enterprise Cloud Pro account.', + statusCode: 201, + responses: [ + { + status: 201, + description: 'Added databases list.', + type: AddRedisCloudDatabaseResponse, + isArray: true, + }, + ], + }) + @UsePipes(new ValidationPipe({ transform: true })) + async addRedisCloudDatabases( + @Body() dto: AddMultipleRedisCloudDatabasesDto, + @Res() res: Response, + ): Promise { + const { databases, ...connectionDetails } = dto; + const result = await this.instancesBusinessService.addRedisCloudDatabases( + connectionDetails, + databases, + ); + const hasSuccessResult = result.some( + (addResponse: AddRedisCloudDatabaseResponse) => addResponse.status === AddRedisDatabaseStatus.Success, + ); + if (!hasSuccessResult) { + return res.status(200).json(result); + } + return res.json(result); + } + + @UseInterceptors(new TimeoutInterceptor(ERROR_MESSAGES.CONNECTION_TIMEOUT)) + @Post('sentinel-masters') + @ApiEndpoint({ + statusCode: 201, + description: 'Add masters from Redis Sentinel', + responses: [ + { + status: 201, + description: 'Ok', + type: AddSentinelMasterResponse, + isArray: true, + }, + ], + }) + @UsePipes(new ValidationPipe({ transform: true })) + async addSentinelMasters( + @Body() dto: AddSentinelMastersDto, + @Res() res: Response, + ): Promise { + const result = await this.instancesBusinessService.addSentinelMasters(dto); + const hasSuccessResult = result.some( + (addResponse: AddSentinelMasterResponse) => addResponse.status === AddRedisDatabaseStatus.Success, + ); + if (!hasSuccessResult) { + return res.status(200).json(result); + } + return res.json(result); + } +} diff --git a/redisinsight/api/src/modules/instances/dto/database-instance.dto.ts b/redisinsight/api/src/modules/instances/dto/database-instance.dto.ts new file mode 100644 index 0000000000..b2e2b32f18 --- /dev/null +++ b/redisinsight/api/src/modules/instances/dto/database-instance.dto.ts @@ -0,0 +1,434 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsBoolean, + IsDefined, + IsInt, + IsNotEmpty, + IsNotEmptyObject, + IsOptional, + IsString, + Max, + MaxLength, + Min, + Validate, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ClientCertCollisionValidator, CaCertCollisionValidator } from 'src/validators'; +import { RedisModules } from 'src/constants'; +import { ConnectionType, HostingProvider } from 'src/modules/core/models/database-instance.entity'; + +export class EndpointDto { + @ApiProperty({ + description: + 'The hostname of your Redis database, for example redis.acme.com.' + + ' If your Redis server is running on your local machine, you can enter either 127.0.0.1 or localhost.', + type: String, + default: 'localhost', + }) + @IsNotEmpty() + @IsString({ always: true }) + host: string; + + @ApiProperty({ + description: 'The port your Redis database is available on.', + type: Number, + default: 6379, + }) + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + port: number; +} + +export class RedisModuleDto { + @ApiProperty({ + description: 'Name of the module.', + type: String, + example: RedisModules.RediSearch, + }) + name: string; + + @ApiPropertyOptional({ + description: 'Integer representation of a module version.', + type: Number, + example: 20008, + }) + version?: number; + + @ApiPropertyOptional({ + description: 'Semantic versioning representation of a module version.', + type: String, + example: '2.0.8', + }) + semanticVersion?: string; +} + +export class CaCertDto { + @ApiProperty({ + description: 'Name for your CA Certificate', + type: String, + }) + @IsNotEmpty() + @IsString({ always: true }) + name: string; + + @ApiProperty({ + description: 'Text of the CA Certificate', + type: String, + }) + @IsNotEmpty() + @IsString({ always: true }) + cert: string; +} + +export class ClientCertPairDto { + @ApiProperty({ + description: 'Name for your Client Certificate', + type: String, + }) + @IsNotEmpty() + @IsString({ always: true }) + name: string; + + @ApiProperty({ + description: 'Text of the Private key', + type: String, + }) + @IsNotEmpty() + @IsString({ always: true }) + key: string; + + @ApiProperty({ + description: 'Text of the Certificate', + type: String, + }) + @IsNotEmpty() + @IsString({ always: true }) + cert: string; +} + +export class BasicTlsDto { + @ApiProperty({ + description: 'The certificate returned by the server needs to be verified.', + type: Boolean, + default: false, + }) + @IsDefined() + @Type(() => Boolean) + @IsBoolean({ always: true }) + verifyServerCert: boolean; + + @ApiPropertyOptional({ + description: 'Id of Ca Certificate', + type: String, + }) + @IsNotEmpty() + @IsString({ always: true }) + @IsOptional() + caCertId?: string; + + @ApiPropertyOptional({ + description: + 'Id of Client certificate and private key pair for TLS Mutual authentication.', + type: String, + }) + @IsNotEmpty() + @IsString({ always: true }) + @IsOptional() + clientCertPairId?: string; +} + +export class TlsDto extends BasicTlsDto { + @ApiPropertyOptional({ + description: + 'If the server needs to be authenticated, pass a CA Certificate.', + type: CaCertDto, + }) + @ValidateNested() + @IsNotEmptyObject() + @Type(() => CaCertDto) + @IsOptional() + newCaCert?: CaCertDto; + + @ApiPropertyOptional({ + description: + 'Client certificate and private key pair for TLS Mutual authentication.', + type: ClientCertPairDto, + }) + @ValidateNested() + @IsNotEmptyObject() + @Type(() => ClientCertPairDto) + @IsOptional() + newClientCertPair?: ClientCertPairDto; +} + +export class SentinelMasterDto { + @ApiProperty({ + description: + 'Sentinel master group name. Identifies a group of Redis instances composed of a master and one or more slaves.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ + description: 'Sentinel username, if your database is ACL enabled, otherwise leave this field empty.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + username?: string; + + @ApiPropertyOptional({ + description: + 'The password for your Redis Sentinel master. ' + + 'If your master doesn’t require a password, leave this field empty.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + password?: string; +} + +export class ConnectionOptionsDto extends EndpointDto { + @ApiPropertyOptional({ + description: 'Logical database number.', + type: Number, + example: 0, + }) + @IsInt() + @Max(15) + @Min(0) + @Type(() => Number) + @IsOptional() + db?: number; + + @ApiPropertyOptional({ + description: + 'Database username, if your database is ACL enabled, otherwise leave this field empty.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + username?: string; + + @ApiPropertyOptional({ + description: + 'The password, if any, for your Redis database. ' + + 'If your database doesn’t require a password, leave this field empty.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + password?: string; + + @ApiPropertyOptional({ + description: 'Use TLS to connect.', + type: TlsDto, + }) + @IsOptional() + @IsNotEmptyObject() + @Type(() => TlsDto) + @Validate(CaCertCollisionValidator) + @Validate(ClientCertCollisionValidator) + @ValidateNested() + tls?: TlsDto; + + @ApiPropertyOptional({ + description: 'Redis OSS Sentinel master groups.', + type: SentinelMasterDto, + }) + @IsOptional() + @IsNotEmptyObject() + @Type(() => SentinelMasterDto) + @ValidateNested() + sentinelMaster?: SentinelMasterDto; +} + +export class DatabaseInstanceResponse { + @ApiProperty({ + description: 'Database instance id.', + type: String, + }) + id: string; + + @ApiProperty({ + description: + 'The hostname of your Redis database, for example redis.acme.com.' + + ' If your Redis server is running on your local machine, you can enter either 127.0.0.1 or localhost.', + type: String, + default: 'localhost', + }) + host: string; + + @ApiProperty({ + description: 'The port your Redis database is available on.', + type: Number, + default: 6379, + }) + port: number; + + @ApiProperty({ + description: 'A name for your Redis database.', + type: String, + }) + name: string; + + @ApiPropertyOptional({ + description: 'Logical database number.', + type: Number, + }) + db?: number; + + @ApiPropertyOptional({ + description: + 'Database username, if your database is ACL enabled, otherwise leave this field empty.', + type: String, + }) + username?: string; + + @ApiPropertyOptional({ + description: + 'The password, if any, for your Redis database. ' + + 'If your database doesn’t require a password, leave this field empty.', + type: String, + }) + password?: string; + + @ApiPropertyOptional({ + description: 'Use TLS to connect.', + type: BasicTlsDto, + }) + tls?: BasicTlsDto; + + @ApiProperty({ + description: 'Connection Type', + default: ConnectionType.STANDALONE, + enum: ConnectionType, + }) + connectionType: ConnectionType; + + @ApiProperty({ + description: 'The database name from provider', + }) + nameFromProvider: string | null; + + @ApiProperty({ + description: 'The redis database hosting provider', + example: HostingProvider.RE_CLOUD, + }) + provider: string; + + @ApiProperty({ + description: 'Time of the last connection to the database.', + type: String, + format: 'date-time', + example: '2021-01-06T12:44:39.000Z', + }) + lastConnection: Date; + + @ApiPropertyOptional({ + description: 'Redis OSS Sentinel master group.', + type: SentinelMasterDto, + }) + sentinelMaster?: SentinelMasterDto; + + @ApiPropertyOptional({ + description: 'OSS Cluster Nodes', + type: EndpointDto, + isArray: true, + }) + endpoints?: EndpointDto[]; + + @ApiPropertyOptional({ + description: 'Loaded Redis modules.', + type: RedisModuleDto, + isArray: true, + }) + modules: RedisModuleDto[]; +} + +export class DeleteDatabaseInstanceDto { + @ApiProperty({ + description: 'The unique ID of the database requested', + type: String, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @Type(() => String) + ids: string[]; +} + +export class DeleteDatabaseInstanceResponse { + @ApiProperty({ + description: 'Number of affected database instances', + type: Number, + }) + affected: number; +} + +export class AddDatabaseInstanceDto extends ConnectionOptionsDto { + @ApiProperty({ + description: 'A name for your Redis database.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @MaxLength(500) + name: string; + + nameFromProvider?: string; + + provider?: string; +} + +export class ConnectToRedisDatabaseIndexDto { + @ApiPropertyOptional({ + description: 'Databases index. Redis databases are numbered from 0 to 15.', + type: Number, + minimum: 0, + maximum: 15, + default: 0, + }) + @IsInt() + @Min(0) + @Max(15) + @Type(() => Number) + @IsNotEmpty() + dbNumber?: number; +} + +export class RenameDatabaseInstanceDto { + @ApiProperty({ + description: 'New name', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @MaxLength(500) + newName: string; +} + +export class RenameDatabaseInstanceResponse { + @ApiProperty({ + description: 'Old name', + type: String, + }) + oldName: string; + + @ApiProperty({ + description: 'New name', + type: String, + }) + newName: string; +} diff --git a/redisinsight/api/src/modules/instances/dto/database-overview.dto.ts b/redisinsight/api/src/modules/instances/dto/database-overview.dto.ts new file mode 100644 index 0000000000..c74ec6645d --- /dev/null +++ b/redisinsight/api/src/modules/instances/dto/database-overview.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class DatabaseOverview { + @ApiProperty({ + description: 'Redis database version', + type: String, + }) + version: string; + + @ApiProperty({ + description: 'Total number of bytes allocated by Redis primary shards', + type: Number, + }) + usedMemory: number; + + @ApiProperty({ + description: 'Total number of keys inside Redis primary shards', + type: Number, + }) + totalKeys: number; + + @ApiProperty({ + description: 'Median for connected clients in the all shards', + type: Number, + }) + connectedClients: number; + + @ApiProperty({ + description: 'Sum of current commands per second in the all shards', + type: Number, + }) + opsPerSecond: number; + + @ApiProperty({ + description: 'Sum of current network input in the all shards (kbps)', + type: Number, + }) + networkInKbps: number; + + @ApiProperty({ + description: 'Sum of current network out in the all shards (kbps)', + type: Number, + }) + networkOutKbps: number; + + @ApiProperty({ + description: 'Sum of current cpu usage in the all shards (%)', + type: Number, + }) + cpuUsagePercentage: number; +} diff --git a/redisinsight/api/src/modules/instances/dto/redis-enterprise-cloud.dto.ts b/redisinsight/api/src/modules/instances/dto/redis-enterprise-cloud.dto.ts new file mode 100644 index 0000000000..15fc925c3b --- /dev/null +++ b/redisinsight/api/src/modules/instances/dto/redis-enterprise-cloud.dto.ts @@ -0,0 +1,87 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsDefined, + IsInt, + IsNotEmpty, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { AddRedisDatabaseStatus } from 'src/modules/instances/dto/redis-enterprise-cluster.dto'; +import { + CloudAuthDto, + RedisCloudDatabase, +} from 'src/modules/redis-enterprise/dto/cloud.dto'; + +export class AddRedisCloudDatabaseDto { + @ApiProperty({ + description: 'Subscription id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + subscriptionId: number; + + @ApiProperty({ + description: 'Database id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + databaseId: number; +} + +export class AddMultipleRedisCloudDatabasesDto extends CloudAuthDto { + @ApiProperty({ + description: 'Cloud databases list.', + type: AddRedisCloudDatabaseDto, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @ValidateNested() + @Type(() => AddRedisCloudDatabaseDto) + databases: AddRedisCloudDatabaseDto[]; +} + +export class AddRedisCloudDatabaseResponse { + @ApiProperty({ + description: 'Subscription id', + type: Number, + }) + subscriptionId: number; + + @ApiProperty({ + description: 'Database id', + type: Number, + }) + databaseId: number; + + @ApiProperty({ + description: 'Add Redis Cloud database status', + default: AddRedisDatabaseStatus.Success, + enum: AddRedisDatabaseStatus, + }) + status: AddRedisDatabaseStatus; + + @ApiProperty({ + description: 'Message', + type: String, + }) + message: string; + + @ApiPropertyOptional({ + description: 'The database details.', + type: RedisCloudDatabase, + }) + databaseDetails?: RedisCloudDatabase; + + @ApiPropertyOptional({ + description: 'Error', + }) + error?: string | object; +} diff --git a/redisinsight/api/src/modules/instances/dto/redis-enterprise-cluster.dto.ts b/redisinsight/api/src/modules/instances/dto/redis-enterprise-cluster.dto.ts new file mode 100644 index 0000000000..f21162d3f9 --- /dev/null +++ b/redisinsight/api/src/modules/instances/dto/redis-enterprise-cluster.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, IsArray, IsDefined, IsNumber, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ClusterConnectionDetailsDto, + RedisEnterpriseDatabase, +} from 'src/modules/redis-enterprise/dto/cluster.dto'; + +export enum AddRedisDatabaseStatus { + Success = 'success', + Fail = 'fail', +} + +export class AddRedisEnterpriseDatabasesDto extends ClusterConnectionDetailsDto { + @ApiProperty({ + description: 'The unique IDs of the databases.', + type: Number, + isArray: true, + }) + @IsDefined() + @IsArray() + @IsNumber({}, { each: true }) + @ArrayNotEmpty() + @Type(() => Number) + uids: number[]; +} + +export class AddRedisEnterpriseDatabaseResponse { + @ApiProperty({ + description: 'The unique ID of the database', + type: Number, + }) + uid: number; + + @ApiProperty({ + description: 'Add Redis Enterprise database status', + default: AddRedisDatabaseStatus.Success, + enum: AddRedisDatabaseStatus, + }) + status: AddRedisDatabaseStatus; + + @ApiProperty({ + description: 'Message', + type: String, + }) + message: string; + + @ApiPropertyOptional({ + description: 'The database details.', + type: RedisEnterpriseDatabase, + }) + databaseDetails?: RedisEnterpriseDatabase; + + @ApiPropertyOptional({ + description: 'Error', + }) + error?: string | object; +} diff --git a/redisinsight/api/src/modules/instances/dto/redis-info.dto.ts b/redisinsight/api/src/modules/instances/dto/redis-info.dto.ts new file mode 100644 index 0000000000..438543c75f --- /dev/null +++ b/redisinsight/api/src/modules/instances/dto/redis-info.dto.ts @@ -0,0 +1,92 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RedisNodeInfoResponse { + @ApiProperty({ + description: 'Redis database version', + type: String, + }) + version: string; + + @ApiPropertyOptional({ + description: + 'Value is "master" if the instance is replica of no one, ' + + 'or "slave" if the instance is a replica of some master instance', + enum: ['master', 'slave'], + default: 'master', + }) + role?: 'master' | 'slave'; + + @ApiPropertyOptional({ + description: 'Redis database info from server section', + type: Object, + }) + server?: any; + + @ApiPropertyOptional({ + description: 'The number of Redis databases', + type: Number, + default: 16, + }) + databases?: number; + + @ApiPropertyOptional({ + description: 'Total number of bytes allocated by Redis using', + type: Number, + }) + usedMemory?: number; + + @ApiPropertyOptional({ + description: 'Total number of keys inside Redis database', + type: Number, + }) + totalKeys?: number; + + @ApiPropertyOptional({ + description: + 'Number of client connections (excluding connections from replicas)', + type: Number, + }) + connectedClients?: number; + + @ApiPropertyOptional({ + description: 'Number of seconds since Redis server start', + type: Number, + }) + uptimeInSeconds?: number; + + @ApiPropertyOptional({ + description: 'The cache hit ratio represents the efficiency of cache usage', + type: Number, + }) + hitRatio?: number; +} + +export class RedisDatabaseInfoResponse extends RedisNodeInfoResponse { + @ApiProperty({ + description: 'Redis database version', + type: String, + }) + version: string; + + @ApiPropertyOptional({ + description: 'Nodes info', + type: RedisNodeInfoResponse, + isArray: true, + }) + nodes?: RedisNodeInfoResponse[]; +} + +export class RedisDatabaseModuleDto { + @ApiProperty({ + description: 'Redis module name', + type: String, + }) + name: string; + + @ApiPropertyOptional({ + description: 'Redis module version', + type: Number, + isArray: true, + }) + ver?: number; +} diff --git a/redisinsight/api/src/modules/instances/dto/redis-sentinel.dto.ts b/redisinsight/api/src/modules/instances/dto/redis-sentinel.dto.ts new file mode 100644 index 0000000000..9024091e4e --- /dev/null +++ b/redisinsight/api/src/modules/instances/dto/redis-sentinel.dto.ts @@ -0,0 +1,120 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsDefined, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { AddRedisDatabaseStatus } from 'src/modules/instances/dto/redis-enterprise-cluster.dto'; +import { GetSentinelMastersDto } from 'src/modules/redis-sentinel/dto/sentinel.dto'; +import { DatabaseInstanceResponse } from 'src/modules/instances/dto/database-instance.dto'; + +export class AddSentinelMasterDto { + @ApiProperty({ + description: + 'The name under which the base will be saved in the application.', + type: String, + }) + @IsDefined() + @IsString({ always: true }) + @IsNotEmpty() + @MaxLength(500) + alias: string; + + @ApiProperty({ + description: 'Sentinel master group name.', + type: String, + }) + @IsDefined() + @IsString({ always: true }) + name: string; + + @ApiPropertyOptional({ + description: + 'The username, if your database is ACL enabled, otherwise leave this field empty.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + username?: string; + + @ApiPropertyOptional({ + description: + 'The password, if any, for your Redis database. ' + + 'If your database doesn’t require a password, leave this field empty.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + password?: string; + + @ApiPropertyOptional({ + description: 'Logical database number.', + type: Number, + example: 0, + }) + @IsInt() + @Max(15) + @Min(0) + @Type(() => Number) + @IsOptional() + db?: number; +} + +export class AddSentinelMastersDto extends GetSentinelMastersDto { + @ApiProperty({ + description: 'The Sentinel master group list.', + type: AddSentinelMasterDto, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @ValidateNested() + @Type(() => AddSentinelMasterDto) + masters: AddSentinelMasterDto[]; +} + +export class AddSentinelMasterResponse { + @ApiPropertyOptional({ + description: 'Database instance id.', + type: String, + }) + id?: string; + + @ApiProperty({ + description: 'Sentinel master group name.', + type: String, + }) + name: string; + + @ApiProperty({ + description: 'Add Sentinel Master status', + default: AddRedisDatabaseStatus.Success, + enum: AddRedisDatabaseStatus, + }) + status: AddRedisDatabaseStatus; + + @ApiProperty({ + description: 'Message', + type: String, + }) + message: string; + + @ApiPropertyOptional({ + description: 'Error', + }) + error?: string | object; + + instance?: DatabaseInstanceResponse; +} diff --git a/redisinsight/api/src/modules/instances/instances.module.ts b/redisinsight/api/src/modules/instances/instances.module.ts new file mode 100644 index 0000000000..8899ad572a --- /dev/null +++ b/redisinsight/api/src/modules/instances/instances.module.ts @@ -0,0 +1,24 @@ +import { + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from '@nestjs/common'; +import { SharedModule } from 'src/modules/shared/shared.module'; +import { RedisConnectionMiddleware } from 'src/middleware/redis-connection.middleware'; +import { InstancesController } from './controllers/instances/instances.controller'; +import { CertificatesController } from './controllers/certificates/certificates.controller'; + +@Module({ + imports: [SharedModule], + providers: [], + controllers: [InstancesController, CertificatesController], +}) +export class InstancesModule implements NestModule { + // eslint-disable-next-line class-methods-use-this + configure(consumer: MiddlewareConsumer): any { + consumer + .apply(RedisConnectionMiddleware) + .forRoutes({ path: 'instance/:dbInstance/connect', method: RequestMethod.GET }); + } +} diff --git a/redisinsight/api/src/modules/plugin/plugin.controller.ts b/redisinsight/api/src/modules/plugin/plugin.controller.ts new file mode 100644 index 0000000000..4762f7e8e4 --- /dev/null +++ b/redisinsight/api/src/modules/plugin/plugin.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PluginService } from 'src/modules/plugin/plugin.service'; +import { PluginsResponse } from 'src/modules/plugin/plugin.response'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; + +@ApiTags('Plugins') +@Controller('/plugins') +export class PluginController { + constructor( + private readonly pluginService: PluginService, + ) {} + + @ApiEndpoint({ + statusCode: 200, + description: 'Get list of available plugins', + responses: [ + { + status: 200, + type: PluginsResponse, + }, + ], + }) + @Get() + async getAll(): Promise { + return this.pluginService.getAll(); + } +} diff --git a/redisinsight/api/src/modules/plugin/plugin.module.ts b/redisinsight/api/src/modules/plugin/plugin.module.ts new file mode 100644 index 0000000000..49c7757fda --- /dev/null +++ b/redisinsight/api/src/modules/plugin/plugin.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PluginController } from 'src/modules/plugin/plugin.controller'; +import { PluginService } from 'src/modules/plugin/plugin.service'; + +@Module({ + controllers: [PluginController], + providers: [PluginService], + exports: [PluginService], +}) +export class PluginModule {} diff --git a/redisinsight/api/src/modules/plugin/plugin.response.ts b/redisinsight/api/src/modules/plugin/plugin.response.ts new file mode 100644 index 0000000000..d66238b2ec --- /dev/null +++ b/redisinsight/api/src/modules/plugin/plugin.response.ts @@ -0,0 +1,134 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, IsBoolean, + IsDefined, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PluginVisualization { + @ApiProperty({ + type: String, + }) + @IsNotEmpty() + @IsString() + id: string; + + @ApiProperty({ + type: String, + }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ + type: String, + }) + @IsNotEmpty() + @IsString() + activationMethod: string; + + @ApiProperty({ + type: String, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @Type(() => String) + matchCommands: string[]; + + @ApiProperty({ + type: Boolean, + }) + @IsOptional() + @IsNotEmpty() + @IsBoolean() + default?: boolean; + + @ApiProperty({ + type: String, + }) + @IsOptional() + @IsNotEmpty() + @IsString() + iconDark?: string; + + @ApiProperty({ + type: String, + }) + @IsOptional() + @IsNotEmpty() + @IsString() + iconLight?: string; +} + +export class Plugin { + @ApiPropertyOptional({ + description: 'Determine if plugin is built into Redisinsight', + type: Boolean, + }) + internal?: boolean; + + @ApiProperty({ + description: 'Module name from manifest', + type: String, + }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ + description: 'Plugins base url', + type: String, + }) + baseUrl: string; + + @ApiProperty({ + description: 'Uri to main js file on the local server', + type: String, + }) + @IsNotEmpty() + @IsString() + main: string; + + @ApiProperty({ + description: 'Uri to css file on the local server', + type: String, + }) + @IsOptional() + @IsNotEmpty() + @IsString() + styles?: string; + + @ApiProperty({ + description: 'Visualization field from manifest', + type: PluginVisualization, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => PluginVisualization) + visualizations: PluginVisualization[]; +} + +export class PluginsResponse { + @ApiProperty({ + description: 'Uri to static resources required for plugins', + type: String, + }) + static: string; + + @ApiProperty({ + description: 'List of available plugins', + type: Plugin, + isArray: true, + }) + plugins: Plugin[]; +} diff --git a/redisinsight/api/src/modules/plugin/plugin.service.ts b/redisinsight/api/src/modules/plugin/plugin.service.ts new file mode 100644 index 0000000000..3dda4acfe1 --- /dev/null +++ b/redisinsight/api/src/modules/plugin/plugin.service.ts @@ -0,0 +1,64 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { plainToClass } from 'class-transformer'; +import { Validator } from 'class-validator'; +import { readdirSync, existsSync, readFileSync } from 'fs'; +import config from 'src/utils/config'; +import * as path from 'path'; +import { filter } from 'lodash'; +import { PluginsResponse, Plugin } from 'src/modules/plugin/plugin.response'; + +const PATH_CONFIG = config.get('dir_path'); +const SERVER_CONFIG = config.get('server'); + +@Injectable() +export class PluginService { + private logger = new Logger('PluginService'); + + private validator = new Validator(); + + /** + * Get all plugins + */ + async getAll(): Promise { + return { + static: path.posix.join(SERVER_CONFIG.pluginsAssetsUri), + plugins: [ + ...(await this.scanPluginsFolder(PATH_CONFIG.defaultPlugins, SERVER_CONFIG.defaultPluginsUri, true)), + ...(await this.scanPluginsFolder(PATH_CONFIG.customPlugins, SERVER_CONFIG.customPluginsUri)), + ], + }; + } + + private async scanPluginsFolder( + pluginsFolder: string, + urlPrefix: string, + internal: boolean = false, + ): Promise { + const plugins = existsSync(pluginsFolder) ? readdirSync(pluginsFolder) : []; + return filter(await Promise.all(plugins.map(async (pluginFolder) => { + try { + const manifest = JSON.parse( + readFileSync(path.join(pluginsFolder, pluginFolder, 'package.json'), 'utf8'), + ); + + // const plugin = plainToClass(Plugin, manifest, { excludeExtraneousValues: true, strategy: 'exposeAll' }); + const plugin = plainToClass(Plugin, manifest); + await this.validator.validateOrReject(plugin, { + whitelist: true, + }); + + plugin.internal = internal || undefined; + plugin.baseUrl = path.posix.join(urlPrefix, pluginFolder, '/'); + plugin.main = path.posix.join(urlPrefix, pluginFolder, manifest.main); + if (plugin.styles) { + plugin.styles = path.posix.join(urlPrefix, pluginFolder, manifest.styles); + } + + return plugin; + } catch (error) { + this.logger.error(`Error when trying to process plugin ${pluginFolder}`, error); + return undefined; + } + })), (plugin) => !!plugin); + } +} diff --git a/redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts b/redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts new file mode 100644 index 0000000000..a551b0e6bf --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Post, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { TimeoutInterceptor } from 'src/modules/core/interceptors/timeout.interceptor'; +import { ApiTags } from '@nestjs/swagger'; +import { RedisEnterpriseDatabase } from 'src/modules/redis-enterprise/dto/cluster.dto'; +import { + RedisCloudBusinessService, +} from 'src/modules/shared/services/redis-cloud-business/redis-cloud-business.service'; +import { + CloudAuthDto, + GetCloudAccountShortInfoResponse, + GetDatabasesInMultipleCloudSubscriptionsDto, + RedisCloudDatabase, + GetRedisCloudSubscriptionResponse, +} from 'src/modules/redis-enterprise/dto/cloud.dto'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; + +@ApiTags('Redis Enterprise Cloud') +@UsePipes(new ValidationPipe({ transform: true })) +@Controller('cloud') +export class CloudController { + constructor(private redisCloudService: RedisCloudBusinessService) {} + + @Post('get-account') + @UseInterceptors(new TimeoutInterceptor()) + @ApiEndpoint({ + description: 'Get current account', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Account Details.', + type: RedisEnterpriseDatabase, + }, + ], + }) + async getAccount( + @Body() dto: CloudAuthDto, + ): Promise { + return await this.redisCloudService.getAccount(dto); + } + + @Post('get-subscriptions') + @UseInterceptors(new TimeoutInterceptor()) + @ApiEndpoint({ + description: 'Get information about current account’s subscriptions.', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Redis cloud subscription list.', + type: GetRedisCloudSubscriptionResponse, + isArray: true, + }, + ], + }) + async getSubscriptions( + @Body() dto: CloudAuthDto, + ): Promise { + return await this.redisCloudService.getSubscriptions(dto); + } + + @Post('get-databases') + @UseInterceptors(ClassSerializerInterceptor) + @ApiEndpoint({ + description: 'Get databases belonging to subscriptions', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Databases list.', + type: RedisCloudDatabase, + isArray: true, + }, + ], + }) + async getDatabases( + @Body() dto: GetDatabasesInMultipleCloudSubscriptionsDto, + ): Promise { + return await this.redisCloudService.getDatabasesInMultipleSubscriptions( + dto, + ); + } +} diff --git a/redisinsight/api/src/modules/redis-enterprise/controllers/cluster.controller.ts b/redisinsight/api/src/modules/redis-enterprise/controllers/cluster.controller.ts new file mode 100644 index 0000000000..9317fd3259 --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/controllers/cluster.controller.ts @@ -0,0 +1,47 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Post, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { TimeoutInterceptor } from 'src/modules/core/interceptors/timeout.interceptor'; +import { + RedisEnterpriseBusinessService, +} from 'src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service'; +import { + RedisEnterpriseDatabase, + ClusterConnectionDetailsDto, +} from '../dto/cluster.dto'; + +@ApiTags('Redis Enterprise Cluster') +@UsePipes(new ValidationPipe({ transform: true })) +@Controller('cluster') +export class ClusterController { + constructor(private redisEnterpriseService: RedisEnterpriseBusinessService) {} + + @UseInterceptors(ClassSerializerInterceptor) + @Post('get-dbs') + @UseInterceptors(new TimeoutInterceptor()) + @ApiEndpoint({ + description: 'Get all databases in the cluster.', + statusCode: 200, + responses: [ + { + status: 200, + description: 'All databases in the cluster.', + isArray: true, + type: RedisEnterpriseDatabase, + }, + ], + }) + async getDatabases( + @Body() dto: ClusterConnectionDetailsDto, + ): Promise { + return await this.redisEnterpriseService.getDatabases(dto); + } +} diff --git a/redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts b/redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts new file mode 100644 index 0000000000..3d0041d18d --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts @@ -0,0 +1,203 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDefined, IsInt, IsNotEmpty, IsString, +} from 'class-validator'; +import { Exclude, Transform, Type } from 'class-transformer'; +import { RedisCloudSubscriptionStatus } from '../models/redis-cloud-subscriptions'; +import { RedisEnterpriseDatabaseStatus } from '../models/redis-enterprise-database'; + +export class CloudAuthDto { + @ApiProperty({ + description: 'Cloud API account key', + type: String, + }) + @IsDefined() + @IsNotEmpty() + @IsString({ always: true }) + apiKey: string; + + @ApiProperty({ + description: 'Cloud API secret key', + type: String, + }) + @IsDefined() + @IsNotEmpty() + @IsString({ always: true }) + apiSecretKey: string; +} + +export class GetDatabasesInCloudSubscriptionDto extends CloudAuthDto { + @ApiProperty({ + description: 'Subscription Id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + subscriptionId: number; +} + +export class GetDatabaseInCloudSubscriptionDto extends CloudAuthDto { + @ApiProperty({ + description: 'Subscription Id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + subscriptionId: number; + + @ApiProperty({ + description: 'Database Id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + databaseId: number; +} + +export class GetDatabasesInMultipleCloudSubscriptionsDto extends CloudAuthDto { + @ApiProperty({ + description: 'Subscription Ids', + type: Number, + isArray: true, + }) + @IsDefined() + @IsInt({ each: true }) + @Type(() => Number) + @Transform((value: number | number[]) => { + if (typeof value === 'number') { + return [value]; + } + return value; + }) + subscriptionIds: number[]; +} + +export class GetCloudAccountShortInfoResponse { + @ApiProperty({ + description: 'Account id', + type: Number, + }) + accountId: number; + + @ApiProperty({ + description: 'Account name', + type: String, + }) + accountName: string; + + @ApiProperty({ + description: 'Account owner name', + type: String, + }) + ownerName: string; + + @ApiProperty({ + description: 'Account owner email', + type: String, + }) + ownerEmail: string; +} + +export class GetRedisCloudSubscriptionResponse { + @ApiProperty({ + description: 'Subscription id', + type: Number, + }) + id: number; + + @ApiProperty({ + description: 'Subscription name', + type: String, + }) + name: string; + + @ApiProperty({ + description: 'Number of databases in subscription', + type: Number, + }) + numberOfDatabases: number; + + @ApiProperty({ + description: 'Subscription status', + enum: RedisCloudSubscriptionStatus, + default: RedisCloudSubscriptionStatus.Active, + }) + status: RedisCloudSubscriptionStatus; + + @ApiPropertyOptional({ + description: 'Subscription provider', + type: String, + }) + provider?: string; + + @ApiPropertyOptional({ + description: 'Subscription region', + type: String, + }) + region?: string; +} + +export class RedisCloudDatabase { + @ApiProperty({ + description: 'Subscription id', + type: Number, + }) + subscriptionId: number; + + @ApiProperty({ + description: 'Database id', + type: Number, + }) + databaseId: number; + + @ApiProperty({ + description: 'Database name', + type: String, + }) + name: string; + + @ApiProperty({ + description: 'Address your Redis Cloud database is available on', + type: String, + }) + publicEndpoint: string; + + @ApiProperty({ + description: 'Database status', + enum: RedisEnterpriseDatabaseStatus, + default: RedisEnterpriseDatabaseStatus.Active, + }) + status: RedisEnterpriseDatabaseStatus; + + @ApiProperty({ + description: 'Is ssl authentication enabled or not', + type: Boolean, + }) + sslClientAuthentication: boolean; + + @ApiProperty({ + description: 'Information about the modules loaded to the database', + type: String, + isArray: true, + }) + modules: string[]; + + @ApiProperty({ + description: 'Additional database options', + type: Object, + }) + options: any; + + @Exclude() + password?: string; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/redisinsight/api/src/modules/redis-enterprise/dto/cluster.dto.ts b/redisinsight/api/src/modules/redis-enterprise/dto/cluster.dto.ts new file mode 100644 index 0000000000..2b0e6455b5 --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/dto/cluster.dto.ts @@ -0,0 +1,115 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsDefined, IsInt, IsNotEmpty, IsString, +} from 'class-validator'; +import { Exclude, Type } from 'class-transformer'; +import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; + +export class ClusterConnectionDetailsDto { + @ApiProperty({ + description: 'The hostname of your Redis Enterprise.', + type: String, + default: 'localhost', + }) + @IsDefined() + @IsNotEmpty() + @IsString({ always: true }) + host: string; + + @ApiProperty({ + description: 'The port your Redis Enterprise cluster is available on.', + type: Number, + default: 9443, + }) + @IsDefined() + @Type(() => Number) + @IsNotEmpty() + @IsInt({ always: true }) + port: number; + + @ApiProperty({ + description: 'The admin e-mail/username', + type: String, + }) + @IsDefined() + @IsNotEmpty() + @IsString({ always: true }) + username: string; + + @ApiProperty({ + description: 'The admin password', + type: String, + }) + @IsDefined() + @IsNotEmpty() + @IsString({ always: true }) + password: string; +} + +export class RedisEnterpriseDatabase { + @ApiProperty({ + description: 'The unique ID of the database.', + type: Number, + }) + uid: number; + + @ApiProperty({ + description: 'Name of database in cluster.', + type: String, + }) + name: string; + + @ApiProperty({ + description: + 'DNS name your Redis Enterprise cluster database is available on.', + type: String, + }) + dnsName: string; + + @ApiProperty({ + description: + 'Address your Redis Enterprise cluster database is available on.', + type: String, + }) + address: string; + + @ApiProperty({ + description: + 'The port your Redis Enterprise cluster database is available on.', + type: Number, + }) + port: number; + + @ApiProperty({ + description: 'Database status', + enum: RedisEnterpriseDatabaseStatus, + default: RedisEnterpriseDatabaseStatus.Active, + }) + status: RedisEnterpriseDatabaseStatus; + + @ApiProperty({ + description: 'Information about the modules loaded to the database', + type: String, + isArray: true, + }) + modules: string[]; + + @ApiProperty({ + description: 'Is TLS mode enabled?', + type: Boolean, + }) + tls: boolean; + + @ApiProperty({ + description: 'Additional database options', + type: Object, + }) + options: any; + + @Exclude() + password: string | null; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts new file mode 100644 index 0000000000..1d6feb14ea --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts @@ -0,0 +1,22 @@ +export interface IRedisCloudAccount { + id: number; + name: string; + createdTimestamp: string; + updatedTimestamp: string; + key: IRedisCloudAccountKey; +} + +interface IRedisCloudAccountKey { + name: string; + accountId: number; + accountName: string; + allowedSourceIps: string[]; + createdTimestamp: string; + owner: IRedisCloudAccountOwner; + httpSourceIp: string; +} + +interface IRedisCloudAccountOwner { + name: string; + email: string; +} diff --git a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-database.ts b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-database.ts new file mode 100644 index 0000000000..440d23d684 --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-database.ts @@ -0,0 +1,87 @@ +import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; + +export interface IRedisCloudDatabasesResponse { + accountId: number; + subscription: { + subscriptionId: number; + numberOfDatabases: number; + databases: IRedisCloudDatabase[]; + }[]; +} + +export interface IRedisCloudDatabase { + databaseId: number; + name: string; + protocol: RedisCloudDatabaseProtocol; + provider: string; + region: string; + redisVersionCompliance: string; + status: RedisEnterpriseDatabaseStatus; + memoryLimitInGb: number; + memoryUsedInMb: number; + memoryStorage: string; + supportOSSClusterApi: boolean; + dataPersistence: string; + replication: boolean; + periodicBackupPath?: string; + dataEvictionPolicy: string; + throughputMeasurement: { + by: string; + value: number; + }; + activatedOn: string; + lastModified: string; + publicEndpoint: string; + privateEndpoint: string; + replicaOf: { + endpoints: string[]; + }; + clustering: IRedisCloudDatabaseClustering; + security: IRedisCloudDatabaseSecurity; + modules: IRedisCloudDatabaseModule[]; + alerts: IRedisCloudAlert[]; +} + +export enum RedisCloudDatabaseProtocol { + Redis = 'redis', + Memcached = 'memcached', +} + +export enum RedisCloudMemoryStorage { + Ram = 'ram', + RamAndFlash = 'ram-and-flash', +} + +export enum RedisPersistencePolicy { + AofEveryOneSecond = 'aof-every-1-second', + AofEveryWrite = 'aof-every-write', + SnapshotEveryOneHour = 'snapshot-every-1-hour', + SnapshotEverySixHours = 'snapshot-every-6-hours', + SnapshotEveryTwelveHours = 'snapshot-every-12-hours', + None = 'none', +} + +export interface IRedisCloudDatabaseModule { + id: number; + name: string; + version: string; + description?: string; + parameters?: any[]; +} + +interface IRedisCloudDatabaseSecurity { + password?: string; + sslClientAuthentication: boolean; + sourceIps: string[]; +} + +interface IRedisCloudDatabaseClustering { + numberOfShards: number; + regexRules: any[]; + hashingPolicy: string; +} + +interface IRedisCloudAlert { + name: string; + value: number; +} diff --git a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-subscriptions.ts b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-subscriptions.ts new file mode 100644 index 0000000000..ed43e5230f --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-subscriptions.ts @@ -0,0 +1,48 @@ +export interface IRedisCloudSubscriptionsResponse { + accountId: number; + subscriptions: IRedisCloudSubscription[]; +} + +export interface IRedisCloudSubscription { + id: number; + name: string; + status: RedisCloudSubscriptionStatus; + paymentMethodId: number; + memoryStorage: string; + storageEncryption: boolean; + numberOfDatabases: number; + subscriptionPricing: IRedisCloudSubscriptionPricing[]; + cloudDetails: IRedisCloudSubscriptionCloudDetails[]; +} + +interface IRedisCloudSubscriptionCloudDetails { + provider: string; + cloudAccountId: number; + totalSizeInGb: number; + regions: IRedisCloudSubscriptionRegion[]; +} + +interface IRedisCloudSubscriptionPricing { + type: string; + typeDetails?: string; + quantity: number; + quantityMeasurement: string; + pricePerUnit?: number; + priceCurrency?: string; + pricePeriod?: string; +} + +interface IRedisCloudSubscriptionRegion { + region: string; + networking: any[]; + preferredAvailabilityZones: string[]; + multipleAvailabilityZones: boolean; +} + +export enum RedisCloudSubscriptionStatus { + Active = 'active', + NotActivated = 'not_activated', + Deleting = 'deleting', + Pending = 'pending', + Error = 'error', +} diff --git a/redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts b/redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts new file mode 100644 index 0000000000..f8dc510ca9 --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts @@ -0,0 +1,147 @@ +export interface IRedisEnterpriseDatabase { + gradual_src_mode: string; + group_uid: number; + memory_size: number; + last_changed_time: string; + created_time: string; + skip_import_analyze: string; + rack_aware: boolean; + shard_key_regex: any[]; + redis_version: string; + oss_sharding: false; + shard_list: number[]; + authentication_ssl_client_certs: any[]; + backup_progress: any; + import_status: string; + hash_slots_policy: string; + dataset_import_sources: any; + roles_permissions: any[]; + replication: boolean; + authentication_admin_pass: string; + default_user: boolean; + name: string; + crdt_causal_consistency: boolean; + authentication_sasl_pass: string; + import_failure_reason: string; + oss_cluster: boolean; + sync: string; + background_op: any[]; + authentication_ssl_crdt_certs: any; + port: number; + crdt_guid: string; + version: string; + email_alerts: boolean; + max_aof_load_time: number; + crdt_sources: any[]; + auto_upgrade: boolean; + backup_interval: number; + slave_ha_priority: number; + shards_placement: string; + data_persistence: RedisEnterpriseDatabasePersistence; + crdt_sync: string; + backup_status: string; + crdt: boolean; + crdt_replicas: any; + snapshot_policy: IRedisEnterpriseSnapshotPolicy[]; + backup: boolean; + gradual_sync_max_shards_per_source: number; + backup_interval_offset: number; + tls_mode: 'enabled' | 'disabled'; + replica_sync: 'enabled' | 'disabled'; + authentication_redis_pass: string; + implicit_shard_key: boolean; + max_aof_file_size: number; + bigstore: boolean; + max_connections: number; + module_list: IRedisEnterpriseModule[]; + eviction_policy: string; + type: string; + backup_history: number; + sync_sources: any[]; + crdt_ghost_replica_ids: string; + replica_sources: IRedisEnterpriseReplicaSource[]; + shard_block_foreign_keys: boolean; + enforce_client_authentication: string; + crdt_replica_id: number; + crdt_config_version: number; + proxy_policy: string; + aof_policy: RedisEnterpriseDatabaseAofPolicy; + endpoints: IRedisEnterpriseEndpoint[]; + wait_command: boolean; + uid: number; + authentication_sasl_uname: string; + backup_failure_reason: string; + bigstore_ram_size: number; + shard_block_crossslot_keys: boolean; + acl: any[]; + slave_ha: boolean; + internal: boolean; + shards_count: number; + status: RedisEnterpriseDatabaseStatus; + gradual_sync_mode: string; + mkms: boolean; + gradual_src_max_sources: number; + sharding: boolean; + oss_cluster_api_preferred_ip_type: string; + ssl: boolean; + dns_address_master: string; + import_progress: any; +} + +export interface IRedisEnterpriseModule { + module_name: string; + module_id: string; + semantic_version: string; + module_args: string; +} + +interface IRedisEnterpriseSnapshotPolicy { + secs: number; + writes: number; +} + +export interface IRedisEnterpriseReplicaSource { + status: string; + uid: number; + uri: string; + server_cert?: string; + encryption?: boolean; + lag?: number; + rdb_transferred?: number; + last_update?: string; + rdb_size?: number; + last_error?: string; + client_cert?: string; + replication_tls_sni?: string; + compression?: number; +} + +export interface IRedisEnterpriseEndpoint { + oss_cluster_api_preferred_ip_type: string; + uid: string; + dns_name: string; + addr_type: string; + proxy_policy: string; + port: number; + addr: string[]; +} +export enum RedisEnterpriseDatabasePersistence { + Disabled = 'disabled', + Aof = 'aof', + Snapshot = 'snapshot', +} + +export enum RedisEnterpriseDatabaseAofPolicy { + AofEveryOneSecond = 'appendfsync-every-sec', + AofEveryWrite = 'appendfsync-always', +} + +export enum RedisEnterpriseDatabaseStatus { + Pending = 'pending', + CreationFailed = 'creation-failed', + Active = 'active', + ActiveChangePending = 'active-change-pending', + ImportPending = 'import-pending', + DeletePending = 'delete-pending', + Recovery = 'recovery', +} diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts new file mode 100644 index 0000000000..4cdd29ed95 --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/modules/shared/shared.module'; +import { ClusterController } from './controllers/cluster.controller'; +import { CloudController } from './controllers/cloud.controller'; + +@Module({ + imports: [SharedModule], + providers: [], + controllers: [ClusterController, CloudController], +}) +export class RedisEnterpriseModule {} diff --git a/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts b/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts new file mode 100644 index 0000000000..410f9d9e1f --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts @@ -0,0 +1,19 @@ +import { RedisModules } from 'src/constants'; +import { convertRECloudModuleName } from 'src/modules/redis-enterprise/utils/redis-cloud-converter'; + +describe('convertRedisCloudModuleName', () => { + it('should return exist module name', () => { + const input = 'RedisJSON'; + + const output = convertRECloudModuleName(input); + + expect(output).toEqual(RedisModules.RedisJSON); + }); + it('should return non-exist module name', () => { + const input = 'RedisNewModule'; + + const output = convertRECloudModuleName(input); + + expect(output).toEqual(input); + }); +}); diff --git a/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.ts b/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.ts new file mode 100644 index 0000000000..1e8dbac14a --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.ts @@ -0,0 +1,5 @@ +import { RE_CLOUD_MODULES_NAMES } from 'src/constants'; + +export function convertRECloudModuleName(name: string): string { + return RE_CLOUD_MODULES_NAMES[name] ?? name; +} diff --git a/redisinsight/api/src/modules/redis-enterprise/utils/redis-enterprise-converter.spec.ts b/redisinsight/api/src/modules/redis-enterprise/utils/redis-enterprise-converter.spec.ts new file mode 100644 index 0000000000..6b362951fc --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/utils/redis-enterprise-converter.spec.ts @@ -0,0 +1,19 @@ +import { RedisModules } from 'src/constants'; +import { convertREClusterModuleName } from 'src/modules/redis-enterprise/utils/redis-enterprise-converter'; + +describe('convertRedisCloudModuleName', () => { + it('should return exist module name', () => { + const input = 'ReJSON'; + + const output = convertREClusterModuleName(input); + + expect(output).toEqual(RedisModules.RedisJSON); + }); + it('should return non-exist module name', () => { + const input = 'RedisNewModule'; + + const output = convertREClusterModuleName(input); + + expect(output).toEqual(input); + }); +}); diff --git a/redisinsight/api/src/modules/redis-enterprise/utils/redis-enterprise-converter.ts b/redisinsight/api/src/modules/redis-enterprise/utils/redis-enterprise-converter.ts new file mode 100644 index 0000000000..ac170606b9 --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/utils/redis-enterprise-converter.ts @@ -0,0 +1,5 @@ +import { RE_CLUSTER_MODULES_NAMES, RedisModules } from 'src/constants'; + +export function convertREClusterModuleName(name: string): RedisModules { + return RE_CLUSTER_MODULES_NAMES[name] ?? name; +} diff --git a/redisinsight/api/src/modules/redis-sentinel/controllers/sentinel.controller.ts b/redisinsight/api/src/modules/redis-sentinel/controllers/sentinel.controller.ts new file mode 100644 index 0000000000..96d4c7eb21 --- /dev/null +++ b/redisinsight/api/src/modules/redis-sentinel/controllers/sentinel.controller.ts @@ -0,0 +1,49 @@ +import { + Body, + Controller, + Post, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { TimeoutInterceptor } from 'src/modules/core/interceptors/timeout.interceptor'; +import { + RedisSentinelBusinessService, +} from 'src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel'; +import { GetSentinelMastersDto } from 'src/modules/redis-sentinel/dto/sentinel.dto'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; + +@ApiTags('Redis OSS Sentinel') +@Controller('') +@UsePipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), +) +export class SentinelController { + constructor(private redisSentinelService: RedisSentinelBusinessService) {} + + @Post('get-masters') + @UseInterceptors(new TimeoutInterceptor(ERROR_MESSAGES.CONNECTION_TIMEOUT)) + @ApiEndpoint({ + description: 'Get master groups', + statusCode: 200, + responses: [ + { + status: 200, + type: SentinelMaster, + isArray: true, + }, + ], + }) + async getMasters( + @Body() dto: GetSentinelMastersDto, + ): Promise { + return await this.redisSentinelService.connectAndGetMasters(dto); + } +} diff --git a/redisinsight/api/src/modules/redis-sentinel/dto/sentinel.dto.ts b/redisinsight/api/src/modules/redis-sentinel/dto/sentinel.dto.ts new file mode 100644 index 0000000000..4c1a357dc7 --- /dev/null +++ b/redisinsight/api/src/modules/redis-sentinel/dto/sentinel.dto.ts @@ -0,0 +1,53 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsNotEmpty, + IsNotEmptyObject, + IsOptional, + IsString, + Validate, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + CaCertCollisionValidator, + ClientCertCollisionValidator, +} from 'src/validators'; +import { + EndpointDto, + TlsDto, +} from 'src/modules/instances/dto/database-instance.dto'; + +export class GetSentinelMastersDto extends EndpointDto { + @ApiPropertyOptional({ + description: + 'The username, if your database is ACL enabled, otherwise leave this field empty.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + username?: string; + + @ApiPropertyOptional({ + description: + 'The password, if any, for your Redis database. ' + + 'If your database doesn’t require a password, leave this field empty.', + type: String, + }) + @IsString({ always: true }) + @IsNotEmpty() + @IsOptional() + password?: string; + + @ApiPropertyOptional({ + description: 'Use TLS to connect.', + type: TlsDto, + }) + @IsOptional() + @IsNotEmptyObject() + @Type(() => TlsDto) + @Validate(CaCertCollisionValidator) + @Validate(ClientCertCollisionValidator) + @ValidateNested() + tls?: TlsDto; +} diff --git a/redisinsight/api/src/modules/redis-sentinel/models/sentinel.ts b/redisinsight/api/src/modules/redis-sentinel/models/sentinel.ts new file mode 100644 index 0000000000..d7e727b4b5 --- /dev/null +++ b/redisinsight/api/src/modules/redis-sentinel/models/sentinel.ts @@ -0,0 +1,62 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + EndpointDto, + TlsDto, +} from 'src/modules/instances/dto/database-instance.dto'; + +export enum SentinelMasterStatus { + Active = 'active', + Down = 'down', +} + +export class SentinelMaster { + @ApiProperty({ + description: 'The name of Sentinel master.', + type: String, + default: 'mastergroup', + }) + name: string; + + @ApiProperty({ + description: 'The hostname of Sentinel master.', + type: String, + default: 'localhost', + }) + host: string; + + @ApiProperty({ + description: 'The port Sentinel master.', + type: Number, + default: 6379, + }) + port: number; + + @ApiProperty({ + description: 'Sentinel master status', + enum: SentinelMasterStatus, + default: SentinelMasterStatus.Active, + }) + status: SentinelMasterStatus; + + @ApiProperty({ + description: 'The number of slaves.', + type: Number, + default: 0, + }) + numberOfSlaves: number; + + @ApiPropertyOptional({ + description: 'Sentinel master endpoints.', + type: EndpointDto, + isArray: true, + }) + endpoints?: EndpointDto[]; +} + +export interface ISentinelConnectionOptions { + name: string; + sentinels: Array<{ host: string; port: number }>; + sentinelUsername?: string; + sentinelPassword?: string; + tls?: TlsDto; +} diff --git a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.module.ts b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.module.ts new file mode 100644 index 0000000000..cfb491428a --- /dev/null +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/modules/shared/shared.module'; +import { SentinelController } from 'src/modules/redis-sentinel/controllers/sentinel.controller'; + +@Module({ + imports: [SharedModule], + providers: [], + controllers: [SentinelController], +}) +export class RedisSentinelModule {} diff --git a/redisinsight/api/src/modules/shared/services/autodiscovery-analytics.service/autodiscovery-analytics.service.spec.ts b/redisinsight/api/src/modules/shared/services/autodiscovery-analytics.service/autodiscovery-analytics.service.spec.ts new file mode 100644 index 0000000000..6b50121177 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/autodiscovery-analytics.service/autodiscovery-analytics.service.spec.ts @@ -0,0 +1,415 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { + mockRedisCloudDatabaseDto, + mockRedisCloudSubscriptionDto, + mockRedisEnterpriseDatabaseDto, + mockSentinelMasterDto, +} from 'src/__mocks__'; +import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; +import { RedisCloudSubscriptionStatus } from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; +import { SentinelMasterStatus } from 'src/modules/redis-sentinel/models/sentinel'; +import { InternalServerErrorException } from '@nestjs/common'; +import { AutodiscoveryAnalyticsService } from './autodiscovery-analytics.service'; + +describe('AutodiscoveryAnalyticsService', () => { + let service: AutodiscoveryAnalyticsService; + let sendEventMethod; + let sendFailedEventMethod; + const httpException = new InternalServerErrorException(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + AutodiscoveryAnalyticsService, + ], + }).compile(); + + service = await module.get(AutodiscoveryAnalyticsService); + sendEventMethod = jest.spyOn( + service, + 'sendEvent', + ); + sendFailedEventMethod = jest.spyOn( + service, + 'sendFailedEvent', + ); + }); + + describe('sendGetREClusterDbsSucceedEvent', () => { + it('should emit event with active databases', () => { + service.sendGetREClusterDbsSucceedEvent([ + mockRedisEnterpriseDatabaseDto, + mockRedisEnterpriseDatabaseDto, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.REClusterDiscoverySucceed, + { + numberOfActiveDatabases: 2, + totalNumberOfDatabases: 2, + }, + ); + }); + it('should emit event with active and not active database', () => { + service.sendGetREClusterDbsSucceedEvent([ + { + ...mockRedisEnterpriseDatabaseDto, + status: RedisEnterpriseDatabaseStatus.Pending, + }, + mockRedisEnterpriseDatabaseDto, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.REClusterDiscoverySucceed, + { + numberOfActiveDatabases: 1, + totalNumberOfDatabases: 2, + }, + ); + }); + it('should emit event without active databases', () => { + service.sendGetREClusterDbsSucceedEvent([ + { + ...mockRedisEnterpriseDatabaseDto, + status: RedisEnterpriseDatabaseStatus.Pending, + }, + { + ...mockRedisEnterpriseDatabaseDto, + status: RedisEnterpriseDatabaseStatus.Pending, + }, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.REClusterDiscoverySucceed, + { + numberOfActiveDatabases: 0, + totalNumberOfDatabases: 2, + }, + ); + }); + it('should emit GetREClusterDbsSucceed event for empty list', () => { + service.sendGetREClusterDbsSucceedEvent([]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.REClusterDiscoverySucceed, + { + numberOfActiveDatabases: 0, + totalNumberOfDatabases: 0, + }, + ); + }); + it('should emit GetREClusterDbsSucceed event for undefined input value', () => { + service.sendGetREClusterDbsSucceedEvent(undefined); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.REClusterDiscoverySucceed, + { + numberOfActiveDatabases: 0, + totalNumberOfDatabases: 0, + }, + ); + }); + it('should not throw on error when sending GetREClusterDbsSucceed event', () => { + const input: any = {}; + + expect(() => service.sendGetREClusterDbsSucceedEvent(input)).not.toThrow(); + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); + + describe('sendGetREClusterDbsFailedEvent', () => { + it('should emit GetREClusterDbsFailed event', () => { + service.sendGetREClusterDbsFailedEvent(httpException); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.REClusterDiscoveryFailed, + httpException, + ); + }); + }); + + describe('sendGetRECloudSubsSucceedEvent', () => { + it('should emit event with active subscriptions', () => { + service.sendGetRECloudSubsSucceedEvent([ + mockRedisCloudSubscriptionDto, + mockRedisCloudSubscriptionDto, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 2, + totalNumberOfSubscriptions: 2, + }, + ); + }); + it('should emit event with active and not active subscription', () => { + service.sendGetRECloudSubsSucceedEvent([ + { + ...mockRedisCloudSubscriptionDto, + status: RedisCloudSubscriptionStatus.Error, + }, + mockRedisCloudSubscriptionDto, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 1, + totalNumberOfSubscriptions: 2, + }, + ); + }); + it('should emit event without active subscriptions', () => { + service.sendGetRECloudSubsSucceedEvent([ + { + ...mockRedisCloudSubscriptionDto, + status: RedisCloudSubscriptionStatus.Error, + }, + { + ...mockRedisCloudSubscriptionDto, + status: RedisCloudSubscriptionStatus.Error, + }, + ]); + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 0, + totalNumberOfSubscriptions: 2, + }, + ); + }); + it('should emit GetRECloudSubsSucceedEvent event for empty list', () => { + service.sendGetRECloudSubsSucceedEvent([]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 0, + totalNumberOfSubscriptions: 0, + }, + ); + }); + it('should emit GetRECloudSubsSucceedEvent event for undefined input value', () => { + service.sendGetRECloudSubsSucceedEvent(undefined); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 0, + totalNumberOfSubscriptions: 0, + }, + ); + }); + it('should not throw on error when sending GetRECloudSubsSucceedEvent event', () => { + const input: any = {}; + + expect(() => service.sendGetRECloudSubsSucceedEvent(input)).not.toThrow(); + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); + + describe('sendGetRECloudSubsFailedEvent', () => { + it('should emit GetRECloudSubsFailedEvent event', () => { + service.sendGetRECloudSubsFailedEvent(httpException); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, + httpException, + ); + }); + }); + + describe('sendGetRECloudDbsSucceedEvent', () => { + it('should emit event with active databases', () => { + service.sendGetRECloudDbsSucceedEvent([ + mockRedisCloudDatabaseDto, + mockRedisCloudDatabaseDto, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 2, + totalNumberOfDatabases: 2, + }, + ); + }); + it('should emit event with active and not active database', () => { + service.sendGetRECloudDbsSucceedEvent([ + { + ...mockRedisCloudDatabaseDto, + status: RedisEnterpriseDatabaseStatus.Pending, + }, + mockRedisCloudDatabaseDto, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 1, + totalNumberOfDatabases: 2, + }, + ); + }); + it('should emit event without active databases', () => { + service.sendGetRECloudDbsSucceedEvent([ + { + ...mockRedisCloudDatabaseDto, + status: RedisEnterpriseDatabaseStatus.Pending, + }, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 0, + totalNumberOfDatabases: 1, + }, + ); + }); + it('should emit event for empty list', () => { + service.sendGetRECloudDbsSucceedEvent([]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 0, + totalNumberOfDatabases: 0, + }, + ); + }); + it('should emit event for undefined input value', () => { + service.sendGetRECloudDbsSucceedEvent(undefined); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 0, + totalNumberOfDatabases: 0, + }, + ); + }); + it('should not throw on error', () => { + const input: any = {}; + + expect(() => service.sendGetRECloudDbsSucceedEvent(input)).not.toThrow(); + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); + + describe('sendGetRECloudDbsFailedEvent', () => { + it('should emit event', () => { + service.sendGetRECloudDbsFailedEvent(httpException); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoveryFailed, + httpException, + ); + }); + }); + + describe('sendGetSentinelMastersSucceedEvent', () => { + it('should emit event with active master groups', () => { + service.sendGetSentinelMastersSucceedEvent([ + mockSentinelMasterDto, + mockSentinelMasterDto, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.SentinelMasterGroupsDiscoverySucceed, + { + numberOfAvailablePrimaryGroups: 2, + totalNumberOfPrimaryGroups: 2, + totalNumberOfReplicas: 2, + }, + ); + }); + it('should emit event with active and not active master groups', () => { + service.sendGetSentinelMastersSucceedEvent([ + mockSentinelMasterDto, + { + ...mockSentinelMasterDto, + status: SentinelMasterStatus.Down, + numberOfSlaves: 0, + }, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.SentinelMasterGroupsDiscoverySucceed, + { + numberOfAvailablePrimaryGroups: 1, + totalNumberOfPrimaryGroups: 2, + totalNumberOfReplicas: 1, + }, + ); + }); + it('should emit event without active groups', () => { + service.sendGetSentinelMastersSucceedEvent([ + { + ...mockSentinelMasterDto, + status: SentinelMasterStatus.Down, + numberOfSlaves: 0, + }, + { + ...mockSentinelMasterDto, + numberOfSlaves: 0, + status: SentinelMasterStatus.Down, + }, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.SentinelMasterGroupsDiscoverySucceed, + { + numberOfAvailablePrimaryGroups: 0, + totalNumberOfPrimaryGroups: 2, + totalNumberOfReplicas: 0, + }, + ); + }); + it('should emit event for empty list', () => { + service.sendGetSentinelMastersSucceedEvent([]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.SentinelMasterGroupsDiscoverySucceed, + { + numberOfAvailablePrimaryGroups: 0, + totalNumberOfPrimaryGroups: 0, + totalNumberOfReplicas: 0, + }, + ); + }); + it('should emit event for undefined input value', () => { + service.sendGetSentinelMastersSucceedEvent(undefined); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.SentinelMasterGroupsDiscoverySucceed, + { + numberOfAvailablePrimaryGroups: 0, + totalNumberOfPrimaryGroups: 0, + totalNumberOfReplicas: 0, + }, + ); + }); + it('should not throw on error', () => { + const input: any = {}; + + expect(() => service.sendGetSentinelMastersSucceedEvent(input)).not.toThrow(); + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); + + describe('sendGetRECloudSubsFailedEvent', () => { + it('should emit event', () => { + service.sendGetSentinelMastersFailedEvent(httpException); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.SentinelMasterGroupsDiscoveryFailed, + httpException, + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/autodiscovery-analytics.service/autodiscovery-analytics.service.ts b/redisinsight/api/src/modules/shared/services/autodiscovery-analytics.service/autodiscovery-analytics.service.ts new file mode 100644 index 0000000000..e8b62b647a --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/autodiscovery-analytics.service/autodiscovery-analytics.service.ts @@ -0,0 +1,100 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { RedisEnterpriseDatabase } from 'src/modules/redis-enterprise/dto/cluster.dto'; +import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; +import { RedisCloudSubscriptionStatus } from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; +import { GetRedisCloudSubscriptionResponse, RedisCloudDatabase } from 'src/modules/redis-enterprise/dto/cloud.dto'; +import { SentinelMaster, SentinelMasterStatus } from 'src/modules/redis-sentinel/models/sentinel'; +import { TelemetryBaseService } from 'src/modules/shared/services/base/telemetry.base.service'; + +@Injectable() +export class AutodiscoveryAnalyticsService extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendGetREClusterDbsSucceedEvent(databases: RedisEnterpriseDatabase[] = []): void { + try { + this.sendEvent( + TelemetryEvents.REClusterDiscoverySucceed, + { + numberOfActiveDatabases: databases.filter( + (db) => db.status === RedisEnterpriseDatabaseStatus.Active, + ).length, + totalNumberOfDatabases: databases.length, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendGetREClusterDbsFailedEvent(exception: HttpException) { + this.sendFailedEvent(TelemetryEvents.REClusterDiscoveryFailed, exception); + } + + sendGetRECloudSubsSucceedEvent(subscriptions: GetRedisCloudSubscriptionResponse[] = []) { + try { + this.sendEvent( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: subscriptions.filter( + (sub) => sub.status === RedisCloudSubscriptionStatus.Active, + ).length, + totalNumberOfSubscriptions: subscriptions.length, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendGetRECloudSubsFailedEvent(exception: HttpException) { + this.sendFailedEvent(TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, exception); + } + + sendGetRECloudDbsSucceedEvent(databases: RedisCloudDatabase[] = []) { + try { + this.sendEvent( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: databases.filter( + (db) => db.status === RedisEnterpriseDatabaseStatus.Active, + ).length, + totalNumberOfDatabases: databases.length, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendGetRECloudDbsFailedEvent(exception: HttpException) { + this.sendFailedEvent(TelemetryEvents.RECloudDatabasesDiscoveryFailed, exception); + } + + sendGetSentinelMastersSucceedEvent(groups: SentinelMaster[] = []) { + try { + this.sendEvent( + TelemetryEvents.SentinelMasterGroupsDiscoverySucceed, + { + numberOfAvailablePrimaryGroups: groups.filter( + (db) => db.status === SentinelMasterStatus.Active, + ).length, + totalNumberOfPrimaryGroups: groups.length, + totalNumberOfReplicas: groups.reduce( + (sum, group) => sum + group.numberOfSlaves, + 0, + ), + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendGetSentinelMastersFailedEvent(exception: HttpException) { + this.sendFailedEvent(TelemetryEvents.SentinelMasterGroupsDiscoveryFailed, exception); + } +} diff --git a/redisinsight/api/src/modules/shared/services/base/redis-consumer.abstract.service.spec.ts b/redisinsight/api/src/modules/shared/services/base/redis-consumer.abstract.service.spec.ts new file mode 100644 index 0000000000..8e283cb7f8 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/base/redis-consumer.abstract.service.spec.ts @@ -0,0 +1,206 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import * as Redis from 'ioredis-mock'; +import { v4 as uuidv4 } from 'uuid'; +import { mockRepository, mockStandaloneDatabaseEntity } from 'src/__mocks__'; +import { AppTool } from 'src/models'; +import { + IFindRedisClientInstanceByOptions, + IRedisClientInstance, + RedisService, +} from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { DatabaseInstanceEntity } from 'src/modules/core/models/database-instance.entity'; + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +export const mockRedisClientInstance: IRedisClientInstance = { + uuid: uuidv4(), + tool: AppTool.Browser, + instanceId: mockClientOptions.instanceId, + client: new Redis(), + lastTimeUsed: 1619791508019, +}; + +describe('RedisConsumerAbstractService', () => { + let redisService; + let instancesBusinessService; + let consumerInstance; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BrowserToolService, + { + provide: getRepositoryToken(DatabaseInstanceEntity), + useFactory: mockRepository, + }, + { + provide: RedisService, + useFactory: () => ({ + getClientInstance: jest.fn(), + selectDatabase: jest.fn(), + setClientInstance: jest.fn(), + isClientConnected: jest.fn(), + removeClientInstance: jest.fn(), + connectToDatabaseInstance: jest.fn(), + }), + }, + { + provide: InstancesBusinessService, + useFactory: () => ({ + getOneById: jest.fn(), + }), + }, + ], + }).compile(); + + redisService = await module.get(RedisService); + instancesBusinessService = await module.get( + InstancesBusinessService, + ); + consumerInstance = await module.get(BrowserToolService); + }); + + describe('getRedisClient', () => { + beforeEach(() => { + consumerInstance.createNewClient = jest.fn(); + }); + it('create new redis client', async () => { + redisService.getClientInstance.mockReturnValue(null); + consumerInstance.createNewClient.mockResolvedValue( + mockRedisClientInstance.client, + ); + + const result = await consumerInstance.getRedisClient(mockClientOptions); + + expect(result).toEqual(mockRedisClientInstance.client); + expect(consumerInstance.createNewClient).toHaveBeenCalled(); + }); + it('existing client has connection', async () => { + redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); + redisService.isClientConnected.mockReturnValue(true); + + const result = await consumerInstance.getRedisClient(mockClientOptions); + + expect(result).toEqual(mockRedisClientInstance.client); + expect(consumerInstance.createNewClient).not.toHaveBeenCalled(); + expect(redisService.selectDatabase).not.toHaveBeenCalled(); + }); + it('existing client has no connection', async () => { + redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); + redisService.isClientConnected.mockReturnValue(false); + consumerInstance.createNewClient.mockResolvedValue( + mockRedisClientInstance.client, + ); + + const result = await consumerInstance.getRedisClient(mockClientOptions); + + expect(result).toEqual(mockRedisClientInstance.client); + expect(consumerInstance.createNewClient).toHaveBeenCalled(); + }); + it('select redis database by number', async () => { + redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); + redisService.isClientConnected.mockReturnValue(true); + + await expect( + consumerInstance.getRedisClient({ + ...mockClientOptions, + }), + ).resolves.not.toThrow(); + + expect(consumerInstance.createNewClient).not.toHaveBeenCalled(); + }); + it("can't create redis client", async () => { + const error = new BadRequestException( + ' Could not connect to localhost, please check the connection details.', + ); + redisService.getClientInstance.mockReturnValue(null); + consumerInstance.createNewClient.mockRejectedValue(error); + + await expect( + consumerInstance.getRedisClient({ + ...mockClientOptions, + dbNumber: 1, + }), + ).rejects.toThrow(error); + }); + }); + + describe('createNewClient', () => { + beforeEach(() => { + instancesBusinessService.getOneById.mockResolvedValue( + mockStandaloneDatabaseEntity, + ); + }); + it('create new redis client', async () => { + redisService.connectToDatabaseInstance.mockResolvedValue( + mockRedisClientInstance.client, + ); + + const result = await consumerInstance.createNewClient( + mockRedisClientInstance.instanceId, + ); + + expect(result).toEqual(mockRedisClientInstance.client); + }); + it("can't create redis client", async () => { + const error = new BadRequestException( + ' Could not connect to localhost, please check the connection details.', + ); + redisService.connectToDatabaseInstance.mockRejectedValue(error); + + await expect( + consumerInstance.createNewClient(mockRedisClientInstance.instanceId), + ).rejects.toThrow(error); + }); + }); + + describe('execPipelineFromClient', () => { + let client; + const mockPipelineCommands = [['module list'], ['keys', '*']]; + beforeEach(() => { + client = mockRedisClientInstance.client; + client.pipeline = jest.fn(); + }); + it('succeed to execute pipeline from redis client', async () => { + client.pipeline.mockReturnValue({ + exec: jest.fn((callback) => callback([null, []])), + }); + + await expect( + consumerInstance.execPipelineFromClient(client, mockPipelineCommands), + ).resolves.not.toThrow(); + expect(client.pipeline).toHaveBeenCalledWith([ + ['module', 'list'], + ['keys', '*'], + ]); + }); + }); + + describe('execMultiFromClient', () => { + let client; + const mockPipelineCommands = [['module list'], ['keys', '*']]; + beforeEach(() => { + client = mockRedisClientInstance.client; + client.multi = jest.fn(); + }); + it('succeed to execute multi from redis client', async () => { + client.multi.mockReturnValue({ + exec: jest.fn((callback) => callback([null, []])), + }); + + await expect( + consumerInstance.execMultiFromClient(client, mockPipelineCommands), + ).resolves.not.toThrow(); + expect(client.pipeline).toHaveBeenCalledWith([ + ['module', 'list'], + ['keys', '*'], + ]); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/base/redis-consumer.abstract.service.ts b/redisinsight/api/src/modules/shared/services/base/redis-consumer.abstract.service.ts new file mode 100644 index 0000000000..f1fd1892eb --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/base/redis-consumer.abstract.service.ts @@ -0,0 +1,145 @@ +import IORedis from 'ioredis'; +import { v4 as uuidv4 } from 'uuid'; +import { AppTool, ReplyError, IRedisConsumer } from 'src/models'; +import { catchRedisConnectionError, generateRedisConnectionName } from 'src/utils'; +import { + IFindRedisClientInstanceByOptions, + RedisService, +} from 'src/modules/core/services/redis/redis.service'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; + +export abstract class RedisConsumerAbstractService implements IRedisConsumer { + protected redisService: RedisService; + + protected instancesBusinessService: InstancesBusinessService; + + protected consumer: AppTool; + + protected constructor( + consumer: AppTool, + redisService: RedisService, + instancesBusinessService: InstancesBusinessService, + ) { + this.consumer = consumer; + this.redisService = redisService; + this.instancesBusinessService = instancesBusinessService; + } + + abstract execCommand( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommand: any, + args: Array, + ): any; + + abstract execPipeline( + clientOptions: IFindRedisClientInstanceByOptions, + toolCommands: Array< + [toolCommand: any, ...args: Array] + >, + ): Promise<[ReplyError | null, any]>; + + private prepareCommands( + toolCommands: Array<[toolCommand: any, ...args: Array]>, + ): string[][] { + return toolCommands.map((item) => { + const [toolCommand, ...args] = item; + const [command, ...commandArgs] = toolCommand.split(' '); + return [command, ...commandArgs, ...args]; + }); + } + + protected async execPipelineFromClient( + client, + toolCommands: Array< + [toolCommand: any, ...args: Array] + >, + ): Promise<[ReplyError | null, any]> { + return new Promise((resolve, reject) => { + try { + client + .pipeline(this.prepareCommands(toolCommands)) + .exec((error, result) => { + resolve([error, result]); + }); + } catch (e) { + reject(e); + } + }); + } + + protected async execMultiFromClient( + client, + toolCommands: Array< + [toolCommand: any, ...args: Array] + >, + ): Promise<[ReplyError | null, any]> { + return new Promise((resolve, reject) => { + try { + client + .multi(this.prepareCommands(toolCommands)) + .exec((error, result) => { + resolve([error, result]); + }); + } catch (e) { + reject(e); + } + }); + } + + async getRedisClient( + options: IFindRedisClientInstanceByOptions, + ): Promise { + const redisClientInstance = this.redisService.getClientInstance({ + ...options, + tool: this.consumer, + }); + if (!redisClientInstance) { + return await this.createNewClient( + options.instanceId, + options.uuid, + ); + } + const isConnected: boolean = this.redisService.isClientConnected( + redisClientInstance.client, + ); + if (!isConnected) { + this.redisService.removeClientInstance({ + instanceId: redisClientInstance.instanceId, + tool: this.consumer, + }); + return await this.createNewClient( + options.instanceId, + options.uuid, + ); + } + + return redisClientInstance.client; + } + + protected async createNewClient( + instanceId: string, + uuid = uuidv4(), + namespace?: string, + ): Promise { + const instanceDto = await this.instancesBusinessService.getOneById(instanceId); + const connectionName = generateRedisConnectionName(namespace || this.consumer, uuid); + try { + const client = await this.redisService.connectToDatabaseInstance( + instanceDto, + this.consumer, + connectionName, + ); + this.redisService.setClientInstance( + { + uuid, + instanceId, + tool: this.consumer, + }, + client, + ); + return client; + } catch (error) { + throw catchRedisConnectionError(error, instanceDto); + } + } +} diff --git a/redisinsight/api/src/modules/shared/services/base/telemetry.base.service.spec.ts b/redisinsight/api/src/modules/shared/services/base/telemetry.base.service.spec.ts new file mode 100644 index 0000000000..26cb3f12db --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/base/telemetry.base.service.spec.ts @@ -0,0 +1,109 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AppAnalyticsEvents, TelemetryEvents } from 'src/constants'; +import { TelemetryBaseService } from './telemetry.base.service'; + +class Service extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } +} +const httpException = new InternalServerErrorException('Message'); + +describe('TelemetryBaseService', () => { + let service; + let eventEmitter: EventEmitter2; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: EventEmitter2, + useFactory: () => ({ + emit: jest.fn(), + }), + }, + ], + }).compile(); + + eventEmitter = await module.get(EventEmitter2); + service = new Service(eventEmitter); + }); + + describe('sendEvent', () => { + it('should emit event', () => { + service.sendEvent(TelemetryEvents.RedisInstanceAdded, { data: 'Some data' }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.RedisInstanceAdded, + eventData: { data: 'Some data' }, + }); + }); + it('should emit event with empty event data', () => { + service.sendEvent(TelemetryEvents.RedisInstanceAdded); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.RedisInstanceAdded, + eventData: {}, + }); + }); + it('should emit event for undefined event data', () => { + service.sendEvent(TelemetryEvents.RedisInstanceAdded, undefined); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.RedisInstanceAdded, + eventData: {}, + }); + }); + it('should not throw on error', () => { + eventEmitter.emit = jest.fn().mockImplementation(() => { + throw new Error(); + }); + + expect(() => service.sendEvent(TelemetryEvents.RedisInstanceAdded)).not.toThrow(); + }); + }); + + describe('sendFailedEvent', () => { + it('should emit event for custom exception', () => { + service.sendFailedEvent(TelemetryEvents.RedisInstanceAddFailed, httpException); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.RedisInstanceAddFailed, + eventData: { + error: 'Internal Server Error', + }, + }); + }); + it('should emit event for default exception', () => { + service.sendFailedEvent(TelemetryEvents.RedisInstanceAddFailed, new BadRequestException()); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.RedisInstanceAddFailed, + eventData: { + error: 'Bad Request', + }, + }); + }); + it('should emit event with additional event data', () => { + service.sendFailedEvent(TelemetryEvents.RedisInstanceAddFailed, httpException, { data: 'Some data' }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.RedisInstanceAddFailed, + eventData: { + error: 'Internal Server Error', + data: 'Some data', + }, + }); + }); + it('should not throw on error', () => { + eventEmitter.emit = jest.fn().mockImplementation(() => { + throw new Error(); + }); + + expect(() => service.sendFailedEvent(TelemetryEvents.RedisInstanceAdded, httpException)) + .not.toThrow(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/base/telemetry.base.service.ts b/redisinsight/api/src/modules/shared/services/base/telemetry.base.service.ts new file mode 100644 index 0000000000..211e7f2a25 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/base/telemetry.base.service.ts @@ -0,0 +1,36 @@ +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { HttpException } from '@nestjs/common'; +import { AppAnalyticsEvents } from 'src/constants'; + +export abstract class TelemetryBaseService { + protected eventEmitter: EventEmitter2; + + protected constructor(eventEmitter: EventEmitter2) { + this.eventEmitter = eventEmitter; + } + + protected sendEvent(event: string, eventData: object = {}): void { + try { + this.eventEmitter.emit(AppAnalyticsEvents.Track, { + event, + eventData, + }); + } catch (e) { + // continue regardless of error + } + } + + protected sendFailedEvent(event: string, exception: HttpException, eventData: object = {}): void { + try { + this.eventEmitter.emit(AppAnalyticsEvents.Track, { + event, + eventData: { + error: exception.getResponse()['error'] || exception.message, + ...eventData, + }, + }); + } catch (e) { + // continue regardless of error + } + } +} diff --git a/redisinsight/api/src/modules/shared/services/configuration-business/configuration-business.service.spec.ts b/redisinsight/api/src/modules/shared/services/configuration-business/configuration-business.service.spec.ts new file mode 100644 index 0000000000..8f1bb22e50 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/configuration-business/configuration-business.service.spec.ts @@ -0,0 +1,372 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import * as Redis from 'ioredis'; +import { when } from 'jest-when'; +import { IRedisClusterNode, RedisClusterNodeLinkState, ReplyError } from 'src/models'; +import { + mockRedisClientsInfoResponse, + mockRedisClusterFailInfoResponse, + mockRedisClusterNodesResponse, + mockRedisClusterOkInfoResponse, + mockRedisCommandReply, + mockRedisSentinelMasterResponse, + mockRedisServerInfoResponse, + mockStandaloneRedisInfoReply, + mockWhitelistCommandsResponse, +} from 'src/__mocks__'; +import { RedisDatabaseInfoResponse } from 'src/modules/instances/dto/redis-info.dto'; +import { REDIS_MODULES_COMMANDS, RedisModules } from 'src/constants'; +import { ConfigurationBusinessService } from './configuration-business.service'; + +const mockClient = Object.create(Redis.prototype); +const mockClusterNode1 = Object.create(Redis.prototype); +const mockClusterNode2 = Object.create(Redis.prototype); +mockClusterNode1.send_command = jest.fn(); +mockClusterNode2.send_command = jest.fn(); +const mockCluster = Object.create(Redis.Cluster.prototype); + +const mockRedisClusterNodesDto: IRedisClusterNode[] = [ + { + id: '07c37dfeb235213a872192d90877d0cd55635b91', + host: '127.0.0.1', + port: 30004, + replicaOf: 'e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca', + linkState: RedisClusterNodeLinkState.Connected, + slot: undefined, + }, + { + id: 'e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca', + host: '127.0.0.1', + port: 30001, + replicaOf: undefined, + linkState: RedisClusterNodeLinkState.Connected, + slot: '0-16383', + }, +]; + +const mockRedisServerInfoDto = { + redis_version: '6.0.5', + redis_mode: 'standalone', + os: 'Linux 4.15.0-1087-gcp x86_64', + arch_bits: '64', + tcp_port: '11113', + uptime_in_seconds: '1000', +}; + +export const mockRedisGeneralInfo: RedisDatabaseInfoResponse = { + version: mockRedisServerInfoDto.redis_version, + databases: 16, + role: 'master', + server: mockRedisServerInfoDto, + usedMemory: 1000000, + totalKeys: 1, + connectedClients: 1, + uptimeInSeconds: 1000, + hitRatio: 1, +}; + +const mockRedisModuleList = [ + { name: 'ai', ver: 10000 }, + { name: 'graph', ver: 10000 }, + { name: 'rg', ver: 10000 }, + { name: 'bf', ver: 10000 }, + { name: 'ReJSON', ver: 10000 }, + { name: 'search', ver: 10000 }, + { name: 'timeseries', ver: 10000 }, + { name: 'customModule', ver: 10000 }, +].map((item) => ([].concat(...Object.entries(item)))); + +const mockUnknownCommandModule = new Error("unknown command 'module'"); + +describe('ConfigurationBusinessService', () => { + let service; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ConfigurationBusinessService], + }).compile(); + + service = await module.get( + ConfigurationBusinessService, + ); + mockClient.send_command = jest.fn(); + }); + + describe('checkClusterConnection', () => { + it('cluster connection ok', async () => { + when(mockClient.send_command) + .calledWith('cluster', ['info']) + .mockResolvedValue(mockRedisClusterOkInfoResponse); + + const result = await service.checkClusterConnection(mockClient); + + expect(result).toEqual(true); + }); + + it('cluster connection ok', async () => { + when(mockClient.send_command) + .calledWith('cluster', ['info']) + .mockResolvedValue(mockRedisClusterFailInfoResponse); + + const result = await service.checkClusterConnection(mockClient); + + expect(result).toEqual(false); + }); + it('cluster not supported', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + message: 'ERR This instance has cluster support disabled', + command: 'CLUSTER', + }; + when(mockClient.send_command) + .calledWith('cluster', ['info']) + .mockRejectedValue(replyError); + + const result = await service.checkClusterConnection(mockClient); + + expect(result).toEqual(false); + }); + }); + + describe('checkSentinelConnection', () => { + it('sentinel connection ok', async () => { + when(mockClient.send_command) + .calledWith('sentinel', ['masters']) + .mockResolvedValue(mockRedisSentinelMasterResponse); + + const result = await service.checkSentinelConnection(mockClient); + + expect(result).toEqual(true); + }); + it('sentinel not supported', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + message: 'Unknown command `sentinel`', + command: 'SENTINEL', + }; + when(mockClient.send_command) + .calledWith('sentinel', ['masters']) + .mockRejectedValue(replyError); + + const result = await service.checkSentinelConnection(mockClient); + + expect(result).toEqual(false); + }); + }); + + describe('getRedisClusterNodes', () => { + it('should return nodes in a defined format', async () => { + when(mockClient.send_command) + .calledWith('cluster', ['nodes']) + .mockResolvedValue(mockRedisClusterNodesResponse); + + const result = await service.getRedisClusterNodes(mockClient); + + expect(result).toEqual(mockRedisClusterNodesDto); + }); + it('cluster not supported', async () => { + const replyError: ReplyError = { + name: 'ReplyError', + message: 'ERR This instance has cluster support disabled', + command: 'CLUSTER', + }; + when(mockClient.send_command) + .calledWith('cluster', ['nodes']) + .mockRejectedValue(replyError); + + try { + await service.getRedisClusterNodes(mockClient); + fail('Should throw an error'); + } catch (err) { + expect(err).toEqual(replyError); + } + }); + }); + + describe('getDatabasesCount', () => { + it('get databases count', async () => { + when(mockClient.send_command) + .calledWith('config', ['get', 'databases']) + .mockResolvedValue(['databases', '16']); + + const result = await service.getDatabasesCount(mockClient); + + expect(result).toBe(16); + }); + it('get databases count for limited redis db', async () => { + when(mockClient.send_command) + .calledWith('config', ['get', 'databases']) + .mockResolvedValue([]); + + const result = await service.getDatabasesCount(mockClient); + + expect(result).toBe(1); + }); + it('failed to get databases config', async () => { + when(mockClient.send_command) + .calledWith('config', ['get', 'databases']) + .mockRejectedValue(new Error("unknown command 'config'")); + + const result = await service.getDatabasesCount(mockClient); + + expect(result).toBe(1); + }); + }); + + describe('getLoadedModulesList', () => { + it('get modules by using MODULE LIST command', async () => { + when(mockClient.send_command) + .calledWith('module', ['list']) + .mockResolvedValue(mockRedisModuleList); + + const result = await service.getLoadedModulesList(mockClient); + + expect(mockClient.send_command).not.toHaveBeenCalledWith('command', expect.anything()); + expect(result).toEqual([ + { name: RedisModules.RedisAI, version: 10000, semanticVersion: '1.0.0' }, + { name: RedisModules.RedisGraph, version: 10000, semanticVersion: '1.0.0' }, + { name: RedisModules.RedisGears, version: 10000, semanticVersion: '1.0.0' }, + { name: RedisModules.RedisBloom, version: 10000, semanticVersion: '1.0.0' }, + { name: RedisModules.RedisJSON, version: 10000, semanticVersion: '1.0.0' }, + { name: RedisModules.RediSearch, version: 10000, semanticVersion: '1.0.0' }, + { name: RedisModules.RedisTimeSeries, version: 10000, semanticVersion: '1.0.0' }, + { name: 'customModule', version: 10000, semanticVersion: undefined }, + ]); + }); + it('detect all modules by using COMMAND INFO command', async () => { + when(mockClient.send_command) + .calledWith('module', ['list']) + .mockRejectedValue(mockUnknownCommandModule); + when(mockClient.send_command) + .calledWith('command', expect.anything()) + .mockResolvedValue([ + null, + ['somecommand', -1, ['readonly'], 0, 0, -1, []], + ]); + + const result = await service.getLoadedModulesList(mockClient); + + expect(mockClient.send_command).toHaveBeenCalledTimes(REDIS_MODULES_COMMANDS.size + 1); + expect(result).toEqual([ + { name: RedisModules.RedisAI }, + { name: RedisModules.RedisGraph }, + { name: RedisModules.RedisGears }, + { name: RedisModules.RedisBloom }, + { name: RedisModules.RedisJSON }, + { name: RedisModules.RediSearch }, + { name: RedisModules.RedisTimeSeries }, + ]); + }); + it('detect only RediSearch module by using COMMAND INFO command', async () => { + when(mockClient.send_command) + .calledWith('module', ['list']) + .mockRejectedValue(mockUnknownCommandModule); + when(mockClient.send_command) + .calledWith('command', ['info', ...REDIS_MODULES_COMMANDS.get(RedisModules.RediSearch)]) + .mockResolvedValue([['FT.INFO', -1, ['readonly'], 0, 0, -1, []]]); + + const result = await service.getLoadedModulesList(mockClient); + + expect(mockClient.send_command).toHaveBeenCalledTimes(REDIS_MODULES_COMMANDS.size + 1); + expect(result).toEqual([ + { name: RedisModules.RediSearch }, + ]); + }); + it('should return empty array if MODULE LIST and COMMAND command not allowed', async () => { + when(mockClient.send_command) + .calledWith('module', ['list']) + .mockRejectedValue(mockUnknownCommandModule); + when(mockClient.send_command) + .calledWith('command', expect.anything()) + .mockRejectedValue(mockUnknownCommandModule); + + const result = await service.getLoadedModulesList(mockClient); + + expect(result).toEqual([]); + }); + }); + + describe('getRedisGeneralInfo', () => { + beforeEach(() => { + service.getDatabasesCount = jest.fn().mockResolvedValue(16); + }); + it('get general info for redis standalone', async () => { + when(mockClient.send_command) + .calledWith('info') + .mockResolvedValue(mockStandaloneRedisInfoReply); + + const result = await service.getRedisGeneralInfo(mockClient); + + expect(result).toEqual(mockRedisGeneralInfo); + }); + it('get general info for redis standalone without some optional fields', async () => { + const reply: string = `${mockRedisServerInfoResponse + }\r\n${ + mockRedisClientsInfoResponse + }\r\n`; + when(mockClient.send_command).calledWith('info').mockResolvedValue(reply); + + const result = await service.getRedisGeneralInfo(mockClient); + + expect(result).toEqual({ + ...mockRedisGeneralInfo, + totalKeys: undefined, + usedMemory: undefined, + hitRatio: undefined, + role: undefined, + }); + }); + it('get general info for redis cluster', async () => { + mockCluster.nodes = jest + .fn() + .mockReturnValue([mockClusterNode1, mockClusterNode2]); + when(mockClusterNode1.send_command) + .calledWith('info') + .mockResolvedValue(mockStandaloneRedisInfoReply); + when(mockClusterNode2.send_command) + .calledWith('info') + .mockResolvedValue(mockStandaloneRedisInfoReply); + + const result = await service.getRedisGeneralInfo(mockCluster); + + expect(result).toEqual({ + version: mockRedisGeneralInfo.version, + totalKeys: mockRedisGeneralInfo.totalKeys * 2, + usedMemory: mockRedisGeneralInfo.usedMemory * 2, + nodes: [mockRedisGeneralInfo, mockRedisGeneralInfo], + }); + }); + }); + + describe('getPluginWhiteListCommands', () => { + beforeEach(() => { + service.getDatabasesCount = jest.fn().mockResolvedValue(16); + }); + it('should return 2 readonly commands', async () => { + mockClient.send_command.mockResolvedValueOnce(mockRedisCommandReply); + mockClient.send_command.mockResolvedValueOnce([]); + mockClient.send_command.mockResolvedValueOnce([]); + + const result = await service.getPluginWhiteListCommands(mockClient); + + expect(result).toEqual(mockWhitelistCommandsResponse); + }); + it('should return 1 readonly commands excluded by dangerous filter', async () => { + mockClient.send_command.mockResolvedValueOnce(mockRedisCommandReply); + mockClient.send_command.mockResolvedValueOnce(['custom.command']); + mockClient.send_command.mockResolvedValueOnce([]); + + const result = await service.getPluginWhiteListCommands(mockClient); + + expect(result).toEqual(['get']); + }); + it('should return 1 readonly commands excluded by blocking filter', async () => { + mockClient.send_command.mockResolvedValueOnce(mockRedisCommandReply); + mockClient.send_command.mockResolvedValueOnce([]); + mockClient.send_command.mockResolvedValueOnce(['custom.command']); + + const result = await service.getPluginWhiteListCommands(mockClient); + + expect(result).toEqual(['get']); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/configuration-business/configuration-business.service.ts b/redisinsight/api/src/modules/shared/services/configuration-business/configuration-business.service.ts new file mode 100644 index 0000000000..55a8410b0a --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/configuration-business/configuration-business.service.ts @@ -0,0 +1,216 @@ +import { Injectable } from '@nestjs/common'; +import IORedis from 'ioredis'; +import { + filter, + get, + isNil, + map, +} from 'lodash'; +import { + convertBulkStringsToObject, + convertRedisInfoReplyToObject, + convertStringsArrayToObject, + parseClusterNodes, + calculateRedisHitRatio, convertIntToSemanticVersion, +} from 'src/utils'; +import { IRedisModule, IRedisClusterInfo, IRedisClusterNode } from 'src/models'; +import { + pluginUnsupportedCommands, + pluginBlockingCommands, + REDIS_MODULES_COMMANDS, + SUPPORTED_REDIS_MODULES, +} from 'src/constants'; +import { RedisDatabaseInfoResponse } from 'src/modules/instances/dto/redis-info.dto'; +import { RedisModuleDto } from 'src/modules/instances/dto/database-instance.dto'; + +@Injectable() +export class ConfigurationBusinessService { + public async checkClusterConnection(client: IORedis.Redis): Promise { + try { + const reply = await client.send_command('cluster', ['info']); + const clusterInfo: IRedisClusterInfo = convertBulkStringsToObject(reply); + return clusterInfo?.cluster_state === 'ok'; + } catch (e) { + return false; + } + } + + public async checkSentinelConnection( + client: IORedis.Redis, + ): Promise { + try { + await client.send_command('sentinel', ['masters']); + return true; + } catch (e) { + return false; + } + } + + public async getRedisClusterNodes( + client: IORedis.Redis, + ): Promise { + const nodes: any = await client.send_command('cluster', ['nodes']); + return parseClusterNodes(nodes); + } + + public async getRedisGeneralInfo( + client: IORedis.Redis | IORedis.Cluster, + ): Promise { + if (client instanceof IORedis.Cluster) { + return this.getRedisMasterNodesGeneralInfo(client); + } + return this.getRedisNodeGeneralInfo(client); + } + + public async getDatabasesCount(client: any): Promise { + try { + const reply = await client.send_command('config', ['get', 'databases']); + return reply.length ? parseInt(reply[1], 10) : 1; + } catch (e) { + return 1; + } + } + + public async getLoadedModulesList(client: any): Promise { + try { + const reply = await client.send_command('module', ['list']); + const modules = reply.map((module: any[]) => convertStringsArrayToObject(module)); + return this.convertRedisModules(modules); + } catch (e) { + // TODO: detect loaded modules without using ModuleList command + return this.detectRedisModules(client); + } + } + + private async getRedisNodeGeneralInfo( + client: IORedis.Redis, + ): Promise { + const info = convertRedisInfoReplyToObject( + await client.send_command('info'), + ); + const serverInfo = info['server']; + const memoryInfo = info['memory']; + const keyspaceInfo = info['keyspace']; + const clientsInfo = info['clients']; + const statsInfo = info['stats']; + const replicationInfo = info['replication']; + const databases = await this.getDatabasesCount(client); + return { + version: serverInfo?.redis_version, + databases, + role: get(replicationInfo, 'role') || undefined, + totalKeys: this.getRedisNodeTotalKeysCount(keyspaceInfo), + usedMemory: parseInt(get(memoryInfo, 'used_memory'), 10) || undefined, + connectedClients: + parseInt(get(clientsInfo, 'connected_clients'), 10) || undefined, + uptimeInSeconds: + parseInt(get(serverInfo, 'uptime_in_seconds'), 10) || undefined, + hitRatio: this.getRedisHitRatio(statsInfo), + server: serverInfo, + }; + } + + private async getRedisMasterNodesGeneralInfo( + client, + ): Promise { + const nodesResult: RedisDatabaseInfoResponse[] = await Promise.all( + client + .nodes('all') + .map(async (node) => this.getRedisNodeGeneralInfo(node)), + ); + return nodesResult.reduce((prev, cur) => ({ + version: cur.version, + usedMemory: prev.usedMemory + cur.usedMemory, + totalKeys: prev.totalKeys + cur.totalKeys, + nodes: prev?.nodes ? [...prev.nodes, cur] : [prev, cur], + })); + } + + private getRedisNodeTotalKeysCount(keyspaceInfo: object): number { + try { + return Object.values(keyspaceInfo).reduce( + (prev: number, cur: string) => { + const { keys } = convertBulkStringsToObject(cur, ',', '='); + return prev + parseInt(keys, 10); + }, + 0, + ); + } catch (error) { + return undefined; + } + } + + private getRedisHitRatio(statsInfo: object): number { + try { + const keyspaceHits = get(statsInfo, 'keyspace_hits'); + const keyspaceMisses = get(statsInfo, 'keyspace_misses'); + return calculateRedisHitRatio(keyspaceHits, keyspaceMisses); + } catch (error) { + return undefined; + } + } + + private convertRedisModules(modules: IRedisModule[] = []): RedisModuleDto[] { + return modules.map((module): RedisModuleDto => { + const { name, ver } = module; + return { + name: SUPPORTED_REDIS_MODULES[name] ?? name, + version: ver, + semanticVersion: SUPPORTED_REDIS_MODULES[name] + ? convertIntToSemanticVersion(ver) + : undefined, + }; + }); + } + + private async detectRedisModules(client: any): Promise { + const modules: RedisModuleDto[] = []; + await Promise.all(Array.from(REDIS_MODULES_COMMANDS, async ([moduleName, commands]) => { + try { + let commandsInfo = await client.send_command('command', ['info', ...commands]); + commandsInfo = commandsInfo.filter((info) => !isNil(info)); + if (commandsInfo.length) { + modules.push({ name: moduleName }); + } + } catch (e) { + // continue regardless of error + } + })); + return modules; + } + + /** + * Get whitelisted commands available for plugins for particular database + */ + async getPluginWhiteListCommands(client: any): Promise { + let pluginWhiteListCommands = []; + try { + const availableCommands = await client.send_command('command'); + const readOnlyCommands = map(filter(availableCommands, ( + command, + ) => get(command, [2], []) + .includes('readonly')), (command) => command[0]); + + const blackListCommands = [...pluginUnsupportedCommands, ...pluginBlockingCommands]; + try { + const dangerousCommands = await client.send_command('acl', ['cat', 'dangerous']); + blackListCommands.push(...dangerousCommands); + } catch (e) { + // ignore error as acl cat available since Redis 6.0 + } + + try { + const blockingCommands = await client.send_command('acl', ['cat', 'blocking']); + blackListCommands.push(...blockingCommands); + } catch (e) { + // ignore error as acl cat available since Redis 6.0 + } + + pluginWhiteListCommands = filter(readOnlyCommands, (command) => !blackListCommands.includes(command)); + } catch (e) { + // ignore any error to not block main process of client creation + } + + return pluginWhiteListCommands; + } +} diff --git a/redisinsight/api/src/modules/shared/services/instances-business/database.provider.spec.ts b/redisinsight/api/src/modules/shared/services/instances-business/database.provider.spec.ts new file mode 100644 index 0000000000..57d359af1b --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/instances-business/database.provider.spec.ts @@ -0,0 +1,195 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockDataToEncrypt, mockEncryptionService, + mockEncryptResult, + mockQueryBuilderGetMany, + mockQueryBuilderGetOne, + mockRepository, + mockStandaloneDatabaseEntity, + MockType, +} from 'src/__mocks__'; +import { DatabasesProvider } from 'src/modules/shared/services/instances-business/databases.provider'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DatabaseInstanceEntity } from 'src/modules/core/models/database-instance.entity'; +import { Repository } from 'typeorm'; +import { KeytarUnavailableException } from 'src/modules/core/encryption/exceptions'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +const mockDatabaseEntity = { + ...mockStandaloneDatabaseEntity, + password: mockEncryptResult.data, + sentinelMasterPassword: mockEncryptResult.data, +}; + +describe('DatabasesProvider', () => { + let service: DatabasesProvider; + let repository: MockType>; + let encryptionService: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabasesProvider, + { + provide: EncryptionService, + useFactory: mockEncryptionService, + }, + { + provide: getRepositoryToken(DatabaseInstanceEntity), + useFactory: mockRepository, + }, + ], + }).compile(); + + service = module.get(DatabasesProvider); + repository = module.get(getRepositoryToken(DatabaseInstanceEntity)); + encryptionService = module.get(EncryptionService); + + encryptionService.decrypt.mockReturnValue(mockDataToEncrypt); + encryptionService.encrypt.mockReturnValue(mockEncryptResult); + }); + + describe('exists', () => { + it('Should return true if database exists', async () => { + mockQueryBuilderGetOne.mockReturnValueOnce({ id: 'id ' }); + expect(await service.exists(mockStandaloneDatabaseEntity.id)).toEqual(true); + }); + it('Should return false if database not found', async () => { + mockQueryBuilderGetOne.mockReturnValueOnce(null); + expect(await service.exists(mockStandaloneDatabaseEntity.id)).toEqual(false); + }); + }); + + describe('getAll', () => { + it('Should return databases list with decrypted fields', async () => { + mockQueryBuilderGetMany.mockReturnValueOnce([mockDatabaseEntity]); + + expect(await service.getAll()).toEqual([{ + ...mockDatabaseEntity, + password: mockDataToEncrypt, + sentinelMasterPassword: mockDataToEncrypt, + }]); + }); + it('Should return databases list even if decrypt fails', async () => { + mockQueryBuilderGetMany.mockReturnValueOnce([mockDatabaseEntity]); + encryptionService.decrypt.mockRejectedValue(new Error('some error')); + + expect(await service.getAll()).toEqual([{ + ...mockStandaloneDatabaseEntity, + password: null, + sentinelMasterPassword: null, + }]); + }); + }); + + describe('getOneById', () => { + it('Should return database with decrypted fields', async () => { + mockQueryBuilderGetOne.mockReturnValueOnce(mockDatabaseEntity); + + expect(await service.getOneById(mockDatabaseEntity.id)).toEqual({ + ...mockDatabaseEntity, + password: mockDataToEncrypt, + sentinelMasterPassword: mockDataToEncrypt, + }); + }); + it('Should return database even if decrypt fails', async () => { + mockQueryBuilderGetOne.mockReturnValueOnce(mockDatabaseEntity); + encryptionService.decrypt.mockRejectedValue(new Error('some error')); + + expect(await service.getOneById(mockDatabaseEntity.id, true)).toEqual({ + ...mockStandaloneDatabaseEntity, + password: null, + sentinelMasterPassword: null, + }); + }); + it('Should throw an error when failed to decrypt', async () => { + mockQueryBuilderGetOne.mockReturnValueOnce(mockDatabaseEntity); + encryptionService.decrypt.mockRejectedValue(new KeytarUnavailableException()); + + await expect(service.getOneById(mockDatabaseEntity.id)).rejects.toThrowError(KeytarUnavailableException); + }); + it('Should throw an error when database not found', async () => { + mockQueryBuilderGetOne.mockReturnValueOnce(null); + + await expect(service.getOneById(mockDatabaseEntity.id)).rejects.toThrowError(NotFoundException); + }); + }); + + describe('save', () => { + it('Should save entity', async () => { + repository.save.mockReturnValue(mockDatabaseEntity); + encryptionService.decrypt.mockReturnValue(mockDatabaseEntity.password); + + expect(await service.save(mockDatabaseEntity)).toEqual(mockDatabaseEntity); + }); + it('Should throw an error when encryption failed', async () => { + repository.save.mockReturnValue(mockDatabaseEntity); + encryptionService.encrypt.mockRejectedValue(new KeytarUnavailableException()); + + await expect(service.save(mockDatabaseEntity)).rejects.toThrowError(KeytarUnavailableException); + expect(repository.save).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('Should update entity', async () => { + repository.update.mockReturnValue(mockDatabaseEntity); + + expect(await service.update(mockDatabaseEntity.id, mockDatabaseEntity)).toEqual(mockDatabaseEntity); + }); + it('Should throw an error when encryption failed', async () => { + repository.update.mockReturnValue(mockDatabaseEntity); + encryptionService.encrypt.mockRejectedValue(new KeytarUnavailableException()); + + await expect( + service.update(mockDatabaseEntity.id, mockDatabaseEntity), + ).rejects.toThrowError(KeytarUnavailableException); + expect(repository.update).not.toHaveBeenCalled(); + }); + }); + + describe('patch', () => { + it('Should update entity', async () => { + repository.update.mockReturnValue(mockDatabaseEntity); + + expect(await service.patch(mockDatabaseEntity.id, { name: 'some' })).toEqual(mockDatabaseEntity); + }); + it('Should throw an error if password defined', async () => { + repository.update.mockReturnValue(mockDatabaseEntity); + + await expect(service.patch(mockDatabaseEntity.id, { + name: 'some', + password: 'some', + })).rejects.toThrowError(BadRequestException); + expect(repository.update).not.toHaveBeenCalled(); + }); + it('Should throw an error if password passed with null value', async () => { + repository.update.mockReturnValue(mockDatabaseEntity); + + await expect(service.patch(mockDatabaseEntity.id, { + name: 'some', + password: null, + })).rejects.toThrowError(BadRequestException); + expect(repository.update).not.toHaveBeenCalled(); + }); + it('Should throw an error if sentinelMasterPassword defined', async () => { + repository.update.mockReturnValue(mockDatabaseEntity); + + await expect(service.patch(mockDatabaseEntity.id, { + name: 'some', + sentinelMasterPassword: 'some', + })).rejects.toThrowError(BadRequestException); + expect(repository.update).not.toHaveBeenCalled(); + }); + it('Should throw an error if sentinelMasterPassword passed with null value', async () => { + repository.update.mockReturnValue(mockDatabaseEntity); + + await expect(service.patch(mockDatabaseEntity.id, { + name: 'some', + sentinelMasterPassword: null, + })).rejects.toThrowError(BadRequestException); + expect(repository.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/instances-business/databases.provider.ts b/redisinsight/api/src/modules/shared/services/instances-business/databases.provider.ts new file mode 100644 index 0000000000..30d644ed4f --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/instances-business/databases.provider.ts @@ -0,0 +1,196 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DatabaseInstanceEntity } from 'src/modules/core/models/database-instance.entity'; +import { Repository } from 'typeorm'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { EncryptionService } from 'src/modules/core/encryption/encryption.service'; + +@Injectable() +export class DatabasesProvider { + private logger = new Logger('DatabaseProvider'); + + constructor( + @InjectRepository(DatabaseInstanceEntity) + private readonly databasesRepository: Repository, + private readonly encryptionService: EncryptionService, + ) {} + + /** + * Fast check if database exists. + * No need to retrieve any fields. + * @param id + */ + async exists(id: string): Promise { + return !!await this.databasesRepository + .createQueryBuilder('database') + .where({ id }) + .select(['database.id']) + .getOne(); + } + + /** + * Get list of databases from the local db + * Temporary this method will decrypt database entity fields + * todo: remove decryption here and exclude passwords from the databases list response + */ + async getAll(): Promise { + this.logger.log('Getting databases list'); + const entities = await this.databasesRepository + .createQueryBuilder('database') + .select(['database', 'caCert.id', 'caCert.name', 'clientCert.id', 'clientCert.name']) + .leftJoin('database.caCert', 'caCert') + .leftJoin('database.clientCert', 'clientCert') + .getMany(); + + this.logger.log('Succeed to get databases entities'); + + return Promise.all( + entities.map>((entity) => this.decryptEntity(entity, true)), + ); + } + + /** + * Get single database by id from the local db + * @throws NotFoundException in case when no database found + */ + async getOneById( + id: string, + ignoreEncryptionErrors: boolean = false, + ): Promise { + this.logger.log(`Getting database ${id}`); + + const entity = await this.databasesRepository + .createQueryBuilder('database') + .where({ id }) + .select(['database', 'caCert.id', 'caCert.name', 'clientCert.id', 'clientCert.name']) + .leftJoin('database.caCert', 'caCert') + .leftJoin('database.clientCert', 'clientCert') + .getOne(); + + if (!entity) { + this.logger.error(`Database with ${id} was not Found`); + throw new NotFoundException(ERROR_MESSAGES.INVALID_DATABASE_INSTANCE_ID); + } + + this.logger.log(`Succeed to get database ${id}`); + + return this.decryptEntity(entity, ignoreEncryptionErrors); + } + + /** + * Encrypt database and save entire entity + * Should always throw and error in case when unable to encrypt for some reason + * @param database + */ + async save(database: DatabaseInstanceEntity): Promise { + return this.decryptEntity( + await this.databasesRepository.save(await this.encryptEntity(database)), + ); + } + + /** + * This method is needed to fast update database field(s) without care about encryption logic + * Updating fields that require encryption is deprecated use "update" method instead + * + * @param id + * @param data + * @throws BadRequestException error when try to update password or sentinelMasterPassword fields + */ + async patch(id: string, data: QueryDeepPartialEntity) { + if (data.password !== undefined || data.sentinelMasterPassword !== undefined) { + throw new BadRequestException('Deprecated to update password fields here'); + } + + return this.databasesRepository.update(id, data); + } + + /** + * Update entire database entity with fields encryption logic + * Should always throw an encryption error to determine that something wrong + * with encryption strategy + * + * @param id + * @param data + */ + async update(id: string, data: DatabaseInstanceEntity) { + return this.databasesRepository.update(id, await this.encryptEntity(data)); + } + + /** + * Encrypt required database fields based on picked encryption strategy + * Should always throw an encryption error to determine that something wrong + * with encryption strategy + * + * @param entity + * @private + */ + private async encryptEntity(entity: DatabaseInstanceEntity): Promise { + let password = null; + let sentinelMasterPassword = null; + let encryption = null; + + if (entity.password) { + const encryptionResult = await this.encryptionService.encrypt(entity.password); + password = encryptionResult.data; + encryption = encryptionResult.encryption; + } + + if (entity.sentinelMasterPassword) { + const encryptionResult = await this.encryptionService.encrypt(entity.sentinelMasterPassword); + sentinelMasterPassword = encryptionResult.data; + encryption = encryptionResult.encryption; + } + + return { + ...entity, + password, + sentinelMasterPassword, + encryption, + }; + } + + /** + * Decrypt required database fields (password, sentinelMasterPassword) + * This method should optionally not fail (to not block users to navigate across app + * on decryption error, for example, to be able change encryption strategy in the future) + * + * When ignoreErrors = true will return null for failed fields. + * It will cause 401 Unauthorized errors when user tries to connect to redis database + * + * @param entity + * @param ignoreErrors + * @private + */ + private async decryptEntity( + entity: DatabaseInstanceEntity, + ignoreErrors: boolean = false, + ): Promise { + let password = null; + let sentinelMasterPassword = null; + + try { + password = await this.encryptionService.decrypt(entity.password, entity.encryption); + sentinelMasterPassword = await this.encryptionService.decrypt( + entity.sentinelMasterPassword, + entity.encryption, + ); + } catch (error) { + this.logger.error(`Unable to decrypt database ${entity.id} fields`, error); + if (!ignoreErrors) { + throw error; + } + } + + return { + ...entity, + password, + sentinelMasterPassword, + }; + } +} diff --git a/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.spec.ts b/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.spec.ts new file mode 100644 index 0000000000..16e9563bc9 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.spec.ts @@ -0,0 +1,236 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { mockCaCertEntity, mockClientCertEntity, mockStandaloneDatabaseEntity } from 'src/__mocks__'; +import { TelemetryEvents } from 'src/constants'; +import { DatabaseInstanceResponse } from 'src/modules/instances/dto/database-instance.dto'; +import { HostingProvider } from 'src/modules/core/models/database-instance.entity'; +import { + mockRedisGeneralInfo, +} from 'src/modules/shared/services/configuration-business/configuration-business.service.spec'; +import { InstancesAnalyticsService } from './instances-analytics.service'; + +const mockDatabaseInstanceDto: DatabaseInstanceResponse = { + id: mockStandaloneDatabaseEntity.id, + nameFromProvider: null, + provider: HostingProvider.LOCALHOST, + connectionType: mockStandaloneDatabaseEntity.connectionType, + lastConnection: mockStandaloneDatabaseEntity.lastConnection, + host: mockStandaloneDatabaseEntity.host, + port: mockStandaloneDatabaseEntity.port, + name: mockStandaloneDatabaseEntity.name, + username: mockStandaloneDatabaseEntity.username, + password: mockStandaloneDatabaseEntity.password, + tls: { + verifyServerCert: true, + caCertId: mockCaCertEntity.id, + clientCertPairId: mockClientCertEntity.id, + }, + modules: [], +}; +describe('InstancesAnalytics', () => { + let service: InstancesAnalyticsService; + let sendEventMethod; + let sendFailedEventMethod; + const httpException = new InternalServerErrorException(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + InstancesAnalyticsService, + ], + }).compile(); + + service = await module.get(InstancesAnalyticsService); + sendEventMethod = jest.spyOn( + service, + 'sendEvent', + ); + sendFailedEventMethod = jest.spyOn( + service, + 'sendFailedEvent', + ); + }); + + describe('sendInstanceAddedEvent', () => { + it('should emit event with enabled tls', () => { + const instance = mockDatabaseInstanceDto; + service.sendInstanceAddedEvent(instance, mockRedisGeneralInfo); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RedisInstanceAdded, + { + databaseId: instance.id, + connectionType: instance.connectionType, + provider: instance.provider, + useTLS: 'enabled', + verifyTLSCertificate: 'enabled', + useTLSAuthClients: 'enabled', + version: mockRedisGeneralInfo.version, + numberOfKeys: mockRedisGeneralInfo.totalKeys, + numberOfKeysRange: '0 - 500 000', + totalMemory: mockRedisGeneralInfo.usedMemory, + numberedDatabases: mockRedisGeneralInfo.databases, + }, + ); + }); + it('should emit event with disabled tls', () => { + const instance = { + ...mockDatabaseInstanceDto, + tls: undefined, + }; + service.sendInstanceAddedEvent(instance, mockRedisGeneralInfo); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RedisInstanceAdded, + { + databaseId: instance.id, + connectionType: instance.connectionType, + provider: instance.provider, + useTLS: 'disabled', + verifyTLSCertificate: 'disabled', + useTLSAuthClients: 'disabled', + version: mockRedisGeneralInfo.version, + numberOfKeys: mockRedisGeneralInfo.totalKeys, + numberOfKeysRange: '0 - 500 000', + totalMemory: mockRedisGeneralInfo.usedMemory, + numberedDatabases: mockRedisGeneralInfo.databases, + }, + ); + }); + it('should emit event without additional info', () => { + const instance = mockDatabaseInstanceDto; + service.sendInstanceAddedEvent(instance, { + version: mockRedisGeneralInfo.version, + }); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RedisInstanceAdded, + { + databaseId: instance.id, + connectionType: instance.connectionType, + provider: instance.provider, + useTLS: 'enabled', + verifyTLSCertificate: 'enabled', + useTLSAuthClients: 'enabled', + version: mockRedisGeneralInfo.version, + numberOfKeys: undefined, + numberOfKeysRange: undefined, + totalMemory: undefined, + numberedDatabases: undefined, + }, + ); + }); + }); + + describe('sendInstanceEditedEvent', () => { + it('should emit event for manual update by user with disabled tls', () => { + const prev = mockDatabaseInstanceDto; + const cur = { + ...mockDatabaseInstanceDto, + provider: HostingProvider.RE_CLUSTER, + tls: undefined, + }; + service.sendInstanceEditedEvent(prev, cur); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RedisInstanceEditedByUser, + { + databaseId: cur.id, + connectionType: cur.connectionType, + provider: HostingProvider.RE_CLUSTER, + useTLS: 'disabled', + verifyTLSCertificate: 'disabled', + useTLSAuthClients: 'disabled', + previousValues: { + connectionType: prev.connectionType, + provider: prev.provider, + useTLS: 'enabled', + verifyTLSCertificate: 'enabled', + useTLSAuthClients: 'enabled', + }, + }, + ); + }); + it('should emit event for manual update by user with enabled tls', () => { + const prev = { + ...mockDatabaseInstanceDto, + tls: undefined, + }; + const cur = { + ...mockDatabaseInstanceDto, + provider: HostingProvider.RE_CLUSTER, + }; + service.sendInstanceEditedEvent(prev, cur); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RedisInstanceEditedByUser, + { + databaseId: cur.id, + connectionType: cur.connectionType, + provider: HostingProvider.RE_CLUSTER, + useTLS: 'enabled', + verifyTLSCertificate: 'enabled', + useTLSAuthClients: 'enabled', + previousValues: { + connectionType: prev.connectionType, + provider: prev.provider, + useTLS: 'disabled', + verifyTLSCertificate: 'disabled', + useTLSAuthClients: 'disabled', + }, + }, + ); + }); + it('should not emit event if instance updated not by user', () => { + const prev = mockDatabaseInstanceDto; + const cur = { + ...mockDatabaseInstanceDto, + provider: HostingProvider.RE_CLUSTER, + tls: undefined, + }; + service.sendInstanceEditedEvent(prev, cur, false); + + expect(sendEventMethod).not.toHaveBeenCalled(); + }); + }); + + describe('sendInstanceAddFailedEvent', () => { + it('should emit AddFailed event', () => { + service.sendInstanceAddFailedEvent(httpException); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RedisInstanceAddFailed, + httpException, + ); + }); + }); + + describe('sendInstanceDeletedEvent', () => { + it('should emit Deleted event', () => { + service.sendInstanceDeletedEvent(mockDatabaseInstanceDto); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RedisInstanceDeleted, + { + databaseId: mockDatabaseInstanceDto.id, + }, + ); + }); + }); + + describe('sendConnectionFailedEvent', () => { + it('should emit ConnectionFailed event', () => { + service.sendConnectionFailedEvent(mockDatabaseInstanceDto, httpException); + + expect(sendFailedEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RedisInstanceConnectionFailed, + httpException, + { + databaseId: mockDatabaseInstanceDto.id, + }, + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.ts b/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.ts new file mode 100644 index 0000000000..8c5f646b75 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.ts @@ -0,0 +1,102 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { getRangeForNumber, TOTAL_KEYS_BREAKPOINTS } from 'src/utils'; +import { DatabaseInstanceResponse } from 'src/modules/instances/dto/database-instance.dto'; +import { RedisDatabaseInfoResponse } from 'src/modules/instances/dto/redis-info.dto'; +import { TelemetryBaseService } from 'src/modules/shared/services/base/telemetry.base.service'; + +@Injectable() +export class InstancesAnalyticsService extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendInstanceAddedEvent( + instance: DatabaseInstanceResponse, + additionalInfo: RedisDatabaseInfoResponse, + ): void { + try { + this.sendEvent( + TelemetryEvents.RedisInstanceAdded, + { + databaseId: instance.id, + connectionType: instance.connectionType, + provider: instance.provider, + useTLS: instance.tls ? 'enabled' : 'disabled', + verifyTLSCertificate: instance?.tls?.verifyServerCert + ? 'enabled' + : 'disabled', + useTLSAuthClients: instance?.tls?.clientCertPairId + ? 'enabled' + : 'disabled', + version: additionalInfo.version, + numberOfKeys: additionalInfo.totalKeys, + numberOfKeysRange: getRangeForNumber(additionalInfo.totalKeys, TOTAL_KEYS_BREAKPOINTS), + totalMemory: additionalInfo.usedMemory, + numberedDatabases: additionalInfo.databases, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendInstanceAddFailedEvent(exception: HttpException): void { + this.sendFailedEvent(TelemetryEvents.RedisInstanceAddFailed, exception); + } + + sendInstanceEditedEvent( + prev: DatabaseInstanceResponse, + cur: DatabaseInstanceResponse, + manualUpdate: boolean = true, + ): void { + try { + if (manualUpdate) { + this.sendEvent( + TelemetryEvents.RedisInstanceEditedByUser, + { + databaseId: cur.id, + connectionType: cur.connectionType, + provider: cur.provider, + useTLS: cur.tls ? 'enabled' : 'disabled', + verifyTLSCertificate: cur?.tls?.verifyServerCert + ? 'enabled' + : 'disabled', + useTLSAuthClients: cur?.tls?.clientCertPairId ? 'enabled' : 'disabled', + previousValues: { + connectionType: prev.connectionType, + provider: prev.provider, + useTLS: prev.tls ? 'enabled' : 'disabled', + verifyTLSCertificate: prev?.tls?.verifyServerCert + ? 'enabled' + : 'disabled', + useTLSAuthClients: prev?.tls?.clientCertPairId + ? 'enabled' + : 'disabled', + }, + }, + ); + } + } catch (e) { + // continue regardless of error + } + } + + sendInstanceDeletedEvent(instance: DatabaseInstanceResponse): void { + this.sendEvent( + TelemetryEvents.RedisInstanceDeleted, + { + databaseId: instance.id, + }, + ); + } + + sendConnectionFailedEvent(instance: DatabaseInstanceResponse, exception: HttpException): void { + this.sendFailedEvent( + TelemetryEvents.RedisInstanceConnectionFailed, + exception, + { databaseId: instance.id }, + ); + } +} diff --git a/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.spec.ts b/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.spec.ts new file mode 100644 index 0000000000..4fe3d4d819 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.spec.ts @@ -0,0 +1,850 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { + BadRequestException, + GatewayTimeoutException, + NotFoundException, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { filter } from 'lodash'; +import { Repository } from 'typeorm'; +import * as Redis from 'ioredis-mock'; +import { + mockCaCertEntity, + mockClientCertEntity, + mockDatabasesProvider, + mockInstancesAnalyticsService, + mockPluginWhiteListCommandsResponse, + mockRepository, + mockSentinelDatabaseEntity, + mockStandaloneDatabaseEntity, + MockType, +} from 'src/__mocks__'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + DatabaseInstanceEntity, + HostingProvider, +} from 'src/modules/core/models/database-instance.entity'; +import { + AddDatabaseInstanceDto, + DatabaseInstanceResponse, + RenameDatabaseInstanceResponse, +} from 'src/modules/instances/dto/database-instance.dto'; +import { RedisService } from 'src/modules/core/services/redis/redis.service'; +import { + CaCertBusinessService, +} from 'src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service'; +import { + ClientCertBusinessService, +} from 'src/modules/core/services/certificates/client-cert-business/client-cert-business.service'; +import { AppTool } from 'src/models'; +import { + AddSentinelMasterDto, + AddSentinelMasterResponse, + AddSentinelMastersDto, +} from 'src/modules/instances/dto/redis-sentinel.dto'; +import { AddRedisDatabaseStatus } from 'src/modules/instances/dto/redis-enterprise-cluster.dto'; +import { InstancesAnalyticsService } from 'src/modules/shared/services/instances-business/instances-analytics.service'; +import { mockRedisClientInstance } from 'src/modules/shared/services/base/redis-consumer.abstract.service.spec'; +import { + mockRedisGeneralInfo, +} from 'src/modules/shared/services/configuration-business/configuration-business.service.spec'; +import { DatabasesProvider } from 'src/modules/shared/services/instances-business/databases.provider'; +import { KeytarUnavailableException } from 'src/modules/core/encryption/exceptions'; +import { OverviewService } from 'src/modules/shared/services/instances-business/overview.service'; +import { mockDatabaseOverview } from 'src/modules/shared/services/instances-business/overview.service.spec'; +import { InstancesBusinessService } from './instances-business.service'; +import { RedisEnterpriseBusinessService } from '../redis-enterprise-business/redis-enterprise-business.service'; +import { RedisCloudBusinessService } from '../redis-cloud-business/redis-cloud-business.service'; +import { ConfigurationBusinessService } from '../configuration-business/configuration-business.service'; +import { RedisSentinelBusinessService } from '../redis-sentinel-business/redis-sentinel-business.service'; + +const addDatabaseDto: AddDatabaseInstanceDto = { + host: mockStandaloneDatabaseEntity.host, + port: mockStandaloneDatabaseEntity.port, + db: mockStandaloneDatabaseEntity.db, + name: mockStandaloneDatabaseEntity.name, + username: mockStandaloneDatabaseEntity.username, + password: mockStandaloneDatabaseEntity.password, + tls: { + verifyServerCert: true, + caCertId: mockCaCertEntity.id, + clientCertPairId: mockClientCertEntity.id, + }, +}; + +const mockDatabaseDto: DatabaseInstanceResponse = { + id: mockStandaloneDatabaseEntity.id, + nameFromProvider: null, + provider: HostingProvider.LOCALHOST, + connectionType: mockStandaloneDatabaseEntity.connectionType, + lastConnection: mockStandaloneDatabaseEntity.lastConnection, + modules: [], + ...addDatabaseDto, +}; + +const mockSentinelMasterDto: AddSentinelMasterDto = { + alias: 'sentinel-database', + name: 'maseter-group', + username: mockStandaloneDatabaseEntity.username, + password: mockStandaloneDatabaseEntity.password, +}; + +const mockAddSentinelMastersDto: AddSentinelMastersDto = { + host: mockSentinelDatabaseEntity.host, + port: mockSentinelDatabaseEntity.port, + username: mockSentinelDatabaseEntity.username, + password: mockSentinelDatabaseEntity.password, + tls: { + verifyServerCert: true, + caCertId: mockCaCertEntity.id, + clientCertPairId: mockClientCertEntity.id, + }, + masters: [mockSentinelMasterDto], +}; + +const mockAddSentinelMasterSuccessResponse: AddSentinelMasterResponse[] = [ + { + id: mockSentinelDatabaseEntity.id, + name: mockSentinelMasterDto.name, + status: AddRedisDatabaseStatus.Success, + message: 'Added', + }, +]; + +const mockAddSentinelMasterFailResponse: AddSentinelMasterResponse[] = [ + { + name: mockSentinelMasterDto.name, + status: AddRedisDatabaseStatus.Fail, + message: ERROR_MESSAGES.MASTER_GROUP_NOT_EXIST, + error: new NotFoundException( + ERROR_MESSAGES.MASTER_GROUP_NOT_EXIST, + ).getResponse(), + }, +]; + +describe('InstancesBusinessService', () => { + let service: InstancesBusinessService; + let instanceRepository: MockType>; + let databasesProvider: MockType; + let redisService; + let redisConfBusinessService; + let overviewService: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InstancesBusinessService, + { + provide: RedisSentinelBusinessService, + useFactory: () => ({}), + }, + { + provide: RedisEnterpriseBusinessService, + useFactory: () => ({}), + }, + { + provide: RedisCloudBusinessService, + useFactory: () => ({}), + }, + { + provide: OverviewService, + useFactory: () => ({ + getOverview: jest.fn(), + }), + }, + { + provide: InstancesAnalyticsService, + useFactory: mockInstancesAnalyticsService, + }, + { + provide: getRepositoryToken(DatabaseInstanceEntity), + useFactory: mockRepository, + }, + { + provide: RedisService, + useFactory: () => ({ + connectToDatabaseInstance: jest.fn(), + createStandaloneClient: jest.fn(), + setClientInstance: jest.fn(), + isConnected: jest.fn(), + removeClientInstance: jest.fn(), + }), + }, + { + provide: ConfigurationBusinessService, + useFactory: () => ({ + checkClusterConnection: jest.fn(), + checkSentinelConnection: jest.fn(), + getLoadedModulesList: jest.fn(), + getRedisGeneralInfo: jest.fn().mockResolvedValue(mockRedisGeneralInfo), + }), + }, + { provide: CaCertBusinessService, useFactory: () => ({}) }, + { provide: ClientCertBusinessService, useFactory: () => ({}) }, + { + provide: DatabasesProvider, + useFactory: mockDatabasesProvider, + }, + ], + }).compile(); + + service = await module.get( + InstancesBusinessService, + ); + instanceRepository = await module.get( + getRepositoryToken(DatabaseInstanceEntity), + ); + redisConfBusinessService = await module.get( + ConfigurationBusinessService, + ); + redisService = await module.get(RedisService); + overviewService = await module.get(OverviewService); + redisConfBusinessService.getLoadedModulesList.mockResolvedValue([]); + databasesProvider = module.get(DatabasesProvider); + }); + + describe('exists', () => { + it('should return true', async () => { + databasesProvider.exists.mockResolvedValue(true); + + expect(await service.exists(mockDatabaseDto.id)).toEqual(true); + }); + }); + + describe('getAll', () => { + it('get all database instances from the repository', async () => { + databasesProvider.getAll.mockResolvedValue([mockStandaloneDatabaseEntity]); + + expect(await service.getAll()).toEqual([mockDatabaseDto]); + }); + }); + + describe('getOneById', () => { + it('should return database instance', async () => { + databasesProvider.getOneById.mockResolvedValue(mockStandaloneDatabaseEntity); + + expect(await service.getOneById(mockStandaloneDatabaseEntity.id)).toEqual(mockDatabaseDto); + }); + it('should throw not found exception', async () => { + databasesProvider.getOneById.mockRejectedValue(new NotFoundException()); + + await expect( + service.getOneById(mockStandaloneDatabaseEntity.id), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw KeytarUnavailable exception', async () => { + databasesProvider.getOneById.mockRejectedValue(new KeytarUnavailableException()); + + await expect( + service.getOneById(mockStandaloneDatabaseEntity.id), + ).rejects.toThrow(KeytarUnavailableException); + }); + }); + + describe('addDatabase', () => { + beforeEach(() => { + service.getInfo = jest.fn().mockResolvedValue(mockRedisGeneralInfo); + service.createDatabaseEntity = jest + .fn() + .mockReturnValue(mockStandaloneDatabaseEntity); + }); + describe('add Standalone database', () => { + beforeEach(() => { + redisConfBusinessService.checkSentinelConnection.mockReturnValue(false); + redisConfBusinessService.checkClusterConnection.mockReturnValue(false); + }); + + it('successfully add the database instance', async () => { + redisService.createStandaloneClient = jest + .fn() + .mockResolvedValue(new Redis()); + databasesProvider.save.mockResolvedValue(mockStandaloneDatabaseEntity); + + const result = await service.addDatabase(addDatabaseDto); + + expect(redisService.createStandaloneClient).toHaveBeenCalledWith( + addDatabaseDto, + AppTool.Common, + false, + ); + expect(databasesProvider.save).toHaveBeenCalledWith( + mockStandaloneDatabaseEntity, + ); + expect(result).toEqual(mockDatabaseDto); + }); + + it('should throw an error when unable to connect during database creation', async () => { + redisService.createStandaloneClient = jest.fn().mockRejectedValue( + new Error(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), + ); + + await expect(service.addDatabase(addDatabaseDto)).rejects.toThrow( + BadRequestException, + ); + + expect(instanceRepository.save).not.toHaveBeenCalled(); + }); + it('should throw KeytarUnavailable error', async () => { + redisService.createStandaloneClient = jest + .fn() + .mockResolvedValue(new Redis()); + databasesProvider.save.mockRejectedValue(new KeytarUnavailableException()); + + await expect(service.addDatabase(addDatabaseDto)).rejects.toThrow( + KeytarUnavailableException, + ); + }); + }); + }); + + describe('update', () => { + beforeEach(() => { + service.createDatabaseEntity = jest + .fn() + .mockReturnValue(mockStandaloneDatabaseEntity); + }); + + it('successfully update the database instance', async () => { + redisService.createStandaloneClient = jest + .fn() + .mockResolvedValue(new Redis()); + databasesProvider.getOneById.mockResolvedValue(mockStandaloneDatabaseEntity); + databasesProvider.update.mockResolvedValue({ affected: 1 }); + + const result = await service.update( + mockStandaloneDatabaseEntity.id, + mockDatabaseDto, + ); + + expect(databasesProvider.getOneById).toHaveBeenCalledWith(mockStandaloneDatabaseEntity.id); + expect(redisService.createStandaloneClient).toHaveBeenCalledWith( + mockDatabaseDto, + AppTool.Common, + false, + ); + expect(databasesProvider.update).toHaveBeenCalled(); + expect(result).toEqual(mockDatabaseDto); + }); + + it('should throw an error when database not found during update', async () => { + databasesProvider.getOneById.mockRejectedValue(new NotFoundException()); + + await expect( + service.update(mockStandaloneDatabaseEntity.id, addDatabaseDto), + ).rejects.toThrow(NotFoundException); + expect(databasesProvider.update).not.toHaveBeenCalled(); + }); + + it('should throw an error when unable to connect during update', async () => { + service.getOneById = jest + .fn() + .mockResolvedValue(mockStandaloneDatabaseEntity); + redisService.createStandaloneClient = jest + .fn() + .mockRejectedValue(new Error(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB)); + + await expect( + service.update(mockStandaloneDatabaseEntity.id, addDatabaseDto), + ).rejects.toThrow(BadRequestException); + expect(databasesProvider.update).not.toHaveBeenCalled(); + }); + + it('should throw KeytarUnavailable', async () => { + databasesProvider.getOneById.mockResolvedValue(mockStandaloneDatabaseEntity); + redisService.createStandaloneClient = jest.fn() + .mockResolvedValue(new Redis()); + + databasesProvider.update.mockRejectedValue(new KeytarUnavailableException()); + + await expect( + service.update(mockStandaloneDatabaseEntity.id, addDatabaseDto), + ).rejects.toThrow(KeytarUnavailableException); + }); + }); + + describe('delete', () => { + it('successfully delete the database instance', async () => { + databasesProvider.getOneById.mockResolvedValue( + mockStandaloneDatabaseEntity, + ); + + await service.delete(mockStandaloneDatabaseEntity.id); + + expect(instanceRepository.delete).toHaveBeenCalledWith( + mockStandaloneDatabaseEntity.id, + ); + }); + it('should throw an error when database not found during delete', async () => { + databasesProvider.getOneById.mockRejectedValue(new NotFoundException()); + + await expect( + service.delete(mockStandaloneDatabaseEntity.id), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('bulkDelete', () => { + it('successfully delete many database instances', async () => { + const ids = [mockStandaloneDatabaseEntity.id]; + instanceRepository.findByIds.mockResolvedValue([ + mockStandaloneDatabaseEntity, + ]); + instanceRepository.remove.mockResolvedValue([ + { ...mockStandaloneDatabaseEntity, id: null }, + ]); + + const result = await service.bulkDelete(ids); + + expect(result).toEqual({ affected: ids.length }); + }); + }); + + describe('connectToInstance', () => { + beforeEach(() => { + databasesProvider.getOneById.mockResolvedValue(mockStandaloneDatabaseEntity); + }); + + it('successfully connected to the redis database', async () => { + await service.connectToInstance( + mockStandaloneDatabaseEntity.id, + AppTool.Common, + true, + ); + + expect(databasesProvider.getOneById).toHaveBeenCalledWith(mockStandaloneDatabaseEntity.id); + expect(redisService.connectToDatabaseInstance).toHaveBeenCalledWith( + mockDatabaseDto, + AppTool.Common, + 'redisinsight-common-a77b23c1', + ); + }); + + it('should throw an error when database not found during connecting', async () => { + databasesProvider.getOneById.mockRejectedValue(new NotFoundException()); + + await expect( + service.connectToInstance(mockStandaloneDatabaseEntity.id, AppTool.Common), + ).rejects.toThrow(NotFoundException); + expect(redisService.connectToDatabaseInstance).not.toHaveBeenCalled(); + }); + + it('should throw KeytarUnavailable error', async () => { + databasesProvider.getOneById.mockRejectedValue(new KeytarUnavailableException()); + + await expect( + service.connectToInstance(mockStandaloneDatabaseEntity.id), + ).rejects.toThrow(KeytarUnavailableException); + expect(redisService.connectToDatabaseInstance).not.toHaveBeenCalled(); + }); + + it('failed connection to the redis database', async () => { + redisService.connectToDatabaseInstance = jest + .fn() + .mockRejectedValue(new Error(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB)); + + await expect( + service.connectToInstance(mockStandaloneDatabaseEntity.id, AppTool.Common), + ).rejects.toThrow(BadRequestException); + }); + + it('connection error [username or password]', async () => { + redisService.connectToDatabaseInstance = jest + .fn() + .mockRejectedValue(new Error('WRONGPASS incorrect credentials')); + + try { + await service.connectToInstance( + mockStandaloneDatabaseEntity.id, + AppTool.Common, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(UnauthorizedException); + expect(err.message).toEqual(ERROR_MESSAGES.AUTHENTICATION_FAILED()); + } + }); + + it('connection error [incorrect tls cert]', async () => { + redisService.connectToDatabaseInstance = jest + .fn() + .mockRejectedValue(new Error('ERR_OSSL incorrect certificate')); + + try { + await service.connectToInstance( + mockStandaloneDatabaseEntity.id, + AppTool.Common, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual( + ERROR_MESSAGES.INCORRECT_CERTIFICATES( + mockStandaloneDatabaseEntity.name, + ), + ); + } + }); + it('connection error [Connection details are incorrect]', async () => { + redisService.connectToDatabaseInstance = jest + .fn() + .mockRejectedValue(new Error('ENOTFOUND some message')); + + try { + await service.connectToInstance( + mockStandaloneDatabaseEntity.id, + AppTool.Common, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(ServiceUnavailableException); + expect(err.message).toEqual( + ERROR_MESSAGES.INCORRECT_DATABASE_URL( + mockStandaloneDatabaseEntity.name, + ), + ); + } + }); + it('connection error [Connection timeout]', async () => { + redisService.connectToDatabaseInstance = jest + .fn() + .mockRejectedValue(new Error('ETIMEDOUT some message')); + + try { + await service.connectToInstance( + mockStandaloneDatabaseEntity.id, + AppTool.Common, + ); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(GatewayTimeoutException); + expect(err.message).toEqual(ERROR_MESSAGES.CONNECTION_TIMEOUT); + } + }); + }); + + describe('getOverview', () => { + const mockClient = new Redis(); + beforeEach(() => { + mockClient.disconnect = jest.fn(); + service.connectToInstance = jest.fn(); + redisService.getClientInstance = jest.fn() + .mockReturnValue(undefined); + redisService.isClientConnected = jest.fn(); + overviewService.getOverview.mockResolvedValue(mockDatabaseOverview); + }); + it('successfully get overview by using exist client', async () => { + redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); + redisService.isClientConnected = jest.fn().mockReturnValue(true); + + const result = await service.getOverview(mockStandaloneDatabaseEntity.id); + + expect(result).toEqual(mockDatabaseOverview); + expect(service.connectToInstance).not.toHaveBeenCalled(); + expect(overviewService.getOverview).toHaveBeenCalledWith( + mockStandaloneDatabaseEntity.id, + mockRedisClientInstance.client, + ); + }); + it('successfully create new client if if the existing one has no connection', async () => { + redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); + redisService.isClientConnected = jest.fn().mockReturnValue(false); + service.connectToInstance = jest.fn().mockResolvedValue(mockClient); + + const result = await service.getOverview(mockStandaloneDatabaseEntity.id); + + expect(result).toEqual(mockDatabaseOverview); + expect(service.connectToInstance).toHaveBeenCalled(); + expect(overviewService.getOverview).toHaveBeenCalledWith( + mockStandaloneDatabaseEntity.id, + mockClient, + ); + }); + }); + describe('getInfo', () => { + const mockClient = new Redis(); + beforeEach(() => { + mockClient.disconnect = jest.fn(); + service.connectToInstance = jest.fn(); + redisService.getClientInstance = jest.fn().mockReturnValue(undefined); + redisService.isClientConnected = jest.fn(); + }); + it('successfully get redis info by using exist client', async () => { + redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); + redisService.isClientConnected = jest.fn().mockReturnValue(true); + redisConfBusinessService.getRedisGeneralInfo = jest.fn().mockResolvedValue(mockRedisGeneralInfo); + + const result = await service.getInfo(mockStandaloneDatabaseEntity.id); + + expect(result).toEqual(mockRedisGeneralInfo); + expect(service.connectToInstance).not.toHaveBeenCalled(); + expect(redisConfBusinessService.getRedisGeneralInfo).toHaveBeenCalledWith( + mockRedisClientInstance.client, + ); + }); + it('successfully get redis info without storing client', async () => { + service.connectToInstance = jest.fn().mockResolvedValue(mockClient); + redisConfBusinessService.getRedisGeneralInfo = jest.fn().mockResolvedValue(mockRedisGeneralInfo); + + const result = await service.getInfo(mockStandaloneDatabaseEntity.id); + + expect(result).toEqual(mockRedisGeneralInfo); + expect(service.connectToInstance).toHaveBeenCalledWith( + mockStandaloneDatabaseEntity.id, + AppTool.Common, + false, + ); + expect(mockClient.disconnect).toHaveBeenCalled(); + expect(redisConfBusinessService.getRedisGeneralInfo).toHaveBeenCalledWith( + mockClient, + ); + }); + it('successfully create new client if if the existing one has no connection', async () => { + redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); + redisService.isClientConnected = jest.fn().mockReturnValue(false); + service.connectToInstance = jest.fn().mockResolvedValue(mockClient); + redisConfBusinessService.getRedisGeneralInfo = jest.fn().mockResolvedValue(mockRedisGeneralInfo); + + const result = await service.getInfo(mockStandaloneDatabaseEntity.id); + + expect(result).toEqual(mockRedisGeneralInfo); + expect(service.connectToInstance).toHaveBeenCalled(); + expect(redisConfBusinessService.getRedisGeneralInfo).toHaveBeenCalledWith(mockClient); + }); + it('successfully get redis info and store client', async () => { + service.connectToInstance = jest.fn().mockResolvedValue(mockClient); + redisConfBusinessService.getRedisGeneralInfo = jest.fn().mockResolvedValue(mockRedisGeneralInfo); + + const result = await service.getInfo(mockStandaloneDatabaseEntity.id, AppTool.Common, true); + + expect(result).toEqual(mockRedisGeneralInfo); + expect(service.connectToInstance).toHaveBeenCalledWith( + mockStandaloneDatabaseEntity.id, + AppTool.Common, + true, + ); + expect(mockClient.disconnect).not.toHaveBeenCalled(); + expect(redisConfBusinessService.getRedisGeneralInfo).toHaveBeenCalledWith(mockClient); + }); + + it('database instance not found', async () => { + service.connectToInstance = jest.fn().mockRejectedValue(new NotFoundException()); + + await expect( + service.getInfo(mockStandaloneDatabaseEntity.id), + ).rejects.toThrow(NotFoundException); + expect( + redisConfBusinessService.getRedisGeneralInfo, + ).not.toHaveBeenCalled(); + }); + + it('failed connection to the redis database', async () => { + service.connectToInstance = jest + .fn() + .mockRejectedValue( + new BadRequestException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), + ); + + try { + await service.getInfo(mockStandaloneDatabaseEntity.id); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect( + redisConfBusinessService.getRedisGeneralInfo, + ).not.toHaveBeenCalled(); + } + }); + + it('connection error [Connection timeout]', async () => { + service.connectToInstance = jest.fn().mockRejectedValue(new GatewayTimeoutException()); + + try { + await service.getInfo(mockStandaloneDatabaseEntity.id); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(GatewayTimeoutException); + expect( + redisConfBusinessService.getRedisGeneralInfo, + ).not.toHaveBeenCalled(); + } + }); + }); + + describe('rename', () => { + const newName = 'newName'; + it('successfully rename the database instance', async () => { + const mockResult: RenameDatabaseInstanceResponse = { + oldName: mockStandaloneDatabaseEntity.name, + newName, + }; + databasesProvider.getOneById.mockResolvedValue({ + ...mockStandaloneDatabaseEntity, + }); + + const result = await service.rename( + mockStandaloneDatabaseEntity.id, + newName, + ); + + expect(result).toEqual(mockResult); + expect(databasesProvider.getOneById).toHaveBeenCalledWith(mockStandaloneDatabaseEntity.id, true); + expect(databasesProvider.patch).toHaveBeenCalledWith(mockStandaloneDatabaseEntity.id, { + name: newName, + }); + }); + it('database instance not found', async () => { + databasesProvider.getOneById.mockRejectedValue(new NotFoundException()); + + await expect( + service.rename(mockStandaloneDatabaseEntity.id, 'newName'), + ).rejects.toThrow(NotFoundException); + expect(databasesProvider.patch).not.toHaveBeenCalled(); + }); + }); + + describe('addSentinelMasters', () => { + beforeEach(() => { + service.getInfo = jest.fn().mockResolvedValue(mockRedisGeneralInfo); + redisConfBusinessService.checkSentinelConnection.mockReturnValue(true); + }); + + it('successfully added master groups from sentinel', async () => { + redisService.createStandaloneClient.mockResolvedValue(new Redis()); + service.createSentinelDatabaseEntity = jest + .fn() + .mockResolvedValue(mockSentinelDatabaseEntity); + databasesProvider.save.mockResolvedValue(mockSentinelDatabaseEntity); + + const result = await service.addSentinelMasters( + mockAddSentinelMastersDto, + ); + + expect(result).toEqual(mockAddSentinelMasterSuccessResponse); + expect(service.createSentinelDatabaseEntity).toHaveBeenCalledTimes( + result.length, + ); + expect(databasesProvider.save).toHaveBeenCalledTimes( + filter(result, { status: AddRedisDatabaseStatus.Success }).length, + ); + }); + + it('failed to add master groups from sentinel', async () => { + redisService.createStandaloneClient.mockResolvedValue(new Redis()); + service.createSentinelDatabaseEntity = jest + .fn() + .mockRejectedValue( + new NotFoundException(ERROR_MESSAGES.MASTER_GROUP_NOT_EXIST), + ); + + const result = await service.addSentinelMasters( + mockAddSentinelMastersDto, + ); + + expect(result).toEqual(mockAddSentinelMasterFailResponse); + expect(service.createSentinelDatabaseEntity).toHaveBeenCalledTimes( + result.length, + ); + expect(instanceRepository.save).toHaveBeenCalledTimes( + filter(result, { status: AddRedisDatabaseStatus.Success }).length, + ); + }); + + it('wrong database type', async () => { + redisService.createStandaloneClient.mockResolvedValue(new Redis()); + redisConfBusinessService.checkSentinelConnection.mockReturnValue(false); + + try { + await service.addSentinelMasters(mockAddSentinelMastersDto); + fail('Should throw an error'); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual(ERROR_MESSAGES.WRONG_DATABASE_TYPE); + } + }); + + it('connection error [Connection details are incorrect]', async () => { + redisService.createStandaloneClient.mockRejectedValue( + new Error('ENOTFOUND some message'), + ); + + try { + await service.addSentinelMasters(mockAddSentinelMastersDto); + fail('Should throw an error'); + } catch (err) { + expect(err).toBeInstanceOf(ServiceUnavailableException); + expect(err.message).toEqual( + ERROR_MESSAGES.INCORRECT_DATABASE_URL( + `${mockAddSentinelMastersDto.host}:${mockAddSentinelMastersDto.port}`, + ), + ); + } + }); + }); + + describe('getPluginCommands', () => { + const mockClient = new Redis(); + beforeEach(() => { + service.connectToInstance = jest.fn(); + redisService.getClientInstance = jest.fn().mockReturnValue(undefined); + redisService.isClientConnected = jest.fn(); + redisConfBusinessService.getPluginWhiteListCommands = jest + .fn().mockResolvedValue(mockPluginWhiteListCommandsResponse); + }); + it('successfully get plugin commands by using exist client', async () => { + redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); + redisService.isClientConnected = jest.fn().mockReturnValue(true); + redisConfBusinessService.getRedisGeneralInfo = jest.fn().mockResolvedValue(mockRedisGeneralInfo); + + const result = await service.getPluginCommands(mockStandaloneDatabaseEntity.id); + + expect(result).toEqual(mockPluginWhiteListCommandsResponse); + expect(service.connectToInstance).not.toHaveBeenCalled(); + expect(redisConfBusinessService.getPluginWhiteListCommands).toHaveBeenCalledWith( + mockRedisClientInstance.client, + ); + }); + it('successfully get plugin commands without storing client', async () => { + service.connectToInstance = jest.fn().mockResolvedValue(mockClient); + + const result = await service.getPluginCommands(mockStandaloneDatabaseEntity.id); + + expect(result).toEqual(mockPluginWhiteListCommandsResponse); + expect(service.connectToInstance).toHaveBeenCalledWith( + mockStandaloneDatabaseEntity.id, + AppTool.Browser, + true, + ); + expect(redisConfBusinessService.getPluginWhiteListCommands).toHaveBeenCalledWith( + mockClient, + ); + }); + it('successfully get plugin commands and store client', async () => { + service.connectToInstance = jest.fn().mockResolvedValue(mockClient); + + const result = await service.getPluginCommands(mockStandaloneDatabaseEntity.id); + + expect(result).toEqual(mockPluginWhiteListCommandsResponse); + expect(service.connectToInstance).toHaveBeenCalledWith( + mockStandaloneDatabaseEntity.id, + AppTool.Browser, + true, + ); + + expect(redisConfBusinessService.getPluginWhiteListCommands).toHaveBeenCalledWith(mockClient); + }); + + it('throw error database instance not found when trying to get plugin commands', async () => { + service.connectToInstance = jest.fn().mockRejectedValue(new NotFoundException()); + + await expect( + service.getPluginCommands(mockStandaloneDatabaseEntity.id), + ).rejects.toThrow(NotFoundException); + expect( + redisConfBusinessService.getPluginWhiteListCommands, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.ts b/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.ts new file mode 100644 index 0000000000..c0c5af17f6 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.ts @@ -0,0 +1,686 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import IORedis from 'ioredis'; +import { find, omit } from 'lodash'; +import { RedisErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + catchRedisConnectionError, + generateRedisConnectionName, + getHostingProvider, + getRedisConnectionException, +} from 'src/utils'; +import { AppTool, RedisClusterNodeLinkState } from 'src/models'; +import { RedisService } from 'src/modules/core/services/redis/redis.service'; +import { + CaCertBusinessService, +} from 'src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service'; +import { + ClientCertBusinessService, +} from 'src/modules/core/services/certificates/client-cert-business/client-cert-business.service'; +import { + ConnectionType, + DatabaseInstanceEntity, + HostingProvider, +} from 'src/modules/core/models/database-instance.entity'; +import { + AddDatabaseInstanceDto, + DatabaseInstanceResponse, + DeleteDatabaseInstanceResponse, + RenameDatabaseInstanceResponse, +} from 'src/modules/instances/dto/database-instance.dto'; +import { + ClusterConnectionDetailsDto, + RedisEnterpriseDatabase, +} from 'src/modules/redis-enterprise/dto/cluster.dto'; +import { + AddRedisDatabaseStatus, + AddRedisEnterpriseDatabaseResponse, +} from 'src/modules/instances/dto/redis-enterprise-cluster.dto'; +import { CloudAuthDto } from 'src/modules/redis-enterprise/dto/cloud.dto'; +import { + AddRedisCloudDatabaseDto, + AddRedisCloudDatabaseResponse, +} from 'src/modules/instances/dto/redis-enterprise-cloud.dto'; +import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; +import { + AddSentinelMasterResponse, + AddSentinelMastersDto, +} from 'src/modules/instances/dto/redis-sentinel.dto'; +import { RedisDatabaseInfoResponse } from 'src/modules/instances/dto/redis-info.dto'; +import { InstancesAnalyticsService } from 'src/modules/shared/services/instances-business/instances-analytics.service'; +import { OverviewService } from 'src/modules/shared/services/instances-business/overview.service'; +import { DatabaseOverview } from 'src/modules/instances/dto/database-overview.dto'; +import { DatabasesProvider } from 'src/modules/shared/services/instances-business/databases.provider'; +import { convertEntityToDto } from '../../utils/database-entity-converter'; +import { RedisEnterpriseBusinessService } from '../redis-enterprise-business/redis-enterprise-business.service'; +import { RedisCloudBusinessService } from '../redis-cloud-business/redis-cloud-business.service'; +import { ConfigurationBusinessService } from '../configuration-business/configuration-business.service'; +import { RedisSentinelBusinessService } from '../redis-sentinel-business/redis-sentinel-business.service'; + +@Injectable() +export class InstancesBusinessService { + private logger = new Logger('InstancesBusinessService'); + + constructor( + @InjectRepository(DatabaseInstanceEntity) + private instanceRepository: Repository, + private databasesProvider: DatabasesProvider, + private redisService: RedisService, + private caCertBusinessService: CaCertBusinessService, + private clientCertBusinessService: ClientCertBusinessService, + private redisEnterpriseService: RedisEnterpriseBusinessService, + private redisCloudService: RedisCloudBusinessService, + private redisSentinelService: RedisSentinelBusinessService, + private redisConfBusinessService: ConfigurationBusinessService, + private overviewService: OverviewService, + private instancesAnalyticsService: InstancesAnalyticsService, + ) {} + + async exists(id: string) { + this.logger.log(`Checking if database with ${id} exists.`); + return this.databasesProvider.exists(id); + } + + async getAll(): Promise { + try { + return (await this.databasesProvider.getAll()).map(convertEntityToDto); + } catch (error) { + this.logger.error('Failed to get database instance list.', error); + throw new InternalServerErrorException(); + } + } + + async getOneById(id: string): Promise { + return convertEntityToDto(await this.databasesProvider.getOneById(id)); + } + + async addDatabase( + databaseDto: AddDatabaseInstanceDto, + ): Promise { + this.logger.log('Adding database.'); + try { + let databaseEntity: DatabaseInstanceEntity; + const client = await this.redisService.createStandaloneClient( + databaseDto, + AppTool.Common, + false, + ); + const isOssCluster = await this.redisConfBusinessService.checkClusterConnection(client); + const isOssSentinel = await this.redisConfBusinessService.checkSentinelConnection(client); + if (isOssSentinel) { + if (!databaseDto.sentinelMaster) { + throw new Error(RedisErrorCodes.SentinelParamsRequired); + } + databaseEntity = await this.createSentinelDatabaseEntity(databaseDto, client); + } else if (isOssCluster) { + databaseEntity = await this.createClusterDatabaseEntity(databaseDto, client); + } else { + databaseEntity = await this.createDatabaseEntity(databaseDto); + databaseEntity.connectionType = ConnectionType.STANDALONE; + } + const modules = await this.redisConfBusinessService.getLoadedModulesList(client); + databaseEntity.modules = JSON.stringify(modules); + await client.disconnect(); + const result = convertEntityToDto(await this.databasesProvider.save(databaseEntity)); + const redisInfo = await this.getInfo(result.id, AppTool.Common, true); + this.instancesAnalyticsService.sendInstanceAddedEvent(result, redisInfo); + return result; + } catch (error) { + this.logger.error('Failed to add database.', error); + const exception = getRedisConnectionException(error, databaseDto); + this.instancesAnalyticsService.sendInstanceAddFailedEvent(exception); + throw exception; + } + } + + // todo: remove manualUpdate flag logic + async update( + id: string, + databaseDto: AddDatabaseInstanceDto, + manualUpdate: boolean = true, + ): Promise { + this.logger.log(`Updating database instance. id: ${id}`); + const oldEntity = await this.databasesProvider.getOneById(id, true); + + try { + let databaseEntity; + const client = await this.redisService.createStandaloneClient( + databaseDto, + AppTool.Common, + false, + ); + const isOssSentinel = await this.redisConfBusinessService.checkSentinelConnection( + client, + ); + const isOssCluster = await this.redisConfBusinessService.checkClusterConnection( + client, + ); + if (isOssSentinel) { + if (!databaseDto.sentinelMaster) { + throw new Error('SENTINEL_PARAMS_REQUIRED'); + } + databaseEntity = await this.createSentinelDatabaseEntity(databaseDto, client); + } else if (isOssCluster) { + databaseEntity = await this.createClusterDatabaseEntity(databaseDto, client); + } else { + databaseEntity = await this.createDatabaseEntity(databaseDto); + databaseEntity.connectionType = ConnectionType.STANDALONE; + databaseEntity.nodes = null; + databaseEntity.sentinelMasterName = null; + databaseEntity.sentinelMasterPassword = null; + databaseEntity.sentinelMasterUsername = null; + } + const modules = await this.redisConfBusinessService.getLoadedModulesList(client); + await client.disconnect(); + databaseEntity.modules = JSON.stringify(modules); + if (manualUpdate) { + databaseEntity.provider = getHostingProvider(databaseEntity.host); + } + + await this.databasesProvider.update(id, databaseEntity); + const instance = convertEntityToDto(await this.databasesProvider.getOneById(id)); + this.redisService.removeClientInstance({ instanceId: id }); + this.instancesAnalyticsService.sendInstanceEditedEvent( + convertEntityToDto(oldEntity), + instance, + manualUpdate, + ); + return instance; + } catch (error) { + this.logger.error(`Failed to update database instance ${id}`, error); + throw catchRedisConnectionError(error, databaseDto); + } + } + + async delete(id: string): Promise { + this.logger.log(`Deleting database instance. id: ${id}`); + const instance = await this.databasesProvider.getOneById(id, true); + try { + await this.instanceRepository.delete(id); + this.instancesAnalyticsService.sendInstanceDeletedEvent( + convertEntityToDto(instance), + ); + this.logger.log('Succeed to delete database instance.'); + this.redisService.removeClientInstance({ instanceId: id }); + return; + } catch (error) { + this.logger.error(`Failed to delete database instance ${id}`, error); + throw new InternalServerErrorException(); + } + } + + async bulkDelete(ids: string[]): Promise { + this.logger.log(`Deleting many database instances. ids: ${ids}`); + try { + const instances = await this.instanceRepository.findByIds(ids); + instances.forEach((item: DatabaseInstanceEntity) => { + this.redisService.removeClientInstance({ instanceId: item.id }); + this.instancesAnalyticsService.sendInstanceDeletedEvent( + convertEntityToDto(item), + ); + }); + const res = await this.instanceRepository.remove(instances); + this.logger.log( + `Succeed to delete many database instances. Affected: ${res.length}`, + ); + return { affected: res.length }; + } catch (error) { + this.logger.error('Failed to delete many database instances', error); + throw new InternalServerErrorException(); + } + } + + async connectToInstance( + id: string, + tool = AppTool.Common, + storeClientInstance = false, + ): Promise { + this.logger.log(`Connecting to database instance. id: ${id}`); + const instance = convertEntityToDto(await this.databasesProvider.getOneById(id)); + const connectionName = generateRedisConnectionName(tool, id); + + try { + const client = await this.redisService.connectToDatabaseInstance( + instance, + tool, + connectionName, + ); + + // refresh modules list and last connected time + // will be refreshed after user navigate to particular database from the databases list + // Note: move to a different place in case if we need to update such info more often + const modules = await this.redisConfBusinessService.getLoadedModulesList(client); + await this.databasesProvider.patch(id, { + lastConnection: new Date().toISOString(), + modules: JSON.stringify(modules), + }); + + if (storeClientInstance) { + this.redisService.setClientInstance( + { + uuid: instance.id, + instanceId: instance.id, + tool, + }, + client, + ); + } + return client; + } catch (error) { + this.logger.error(`Failed connection to database instance ${id}`, error); + const exception = getRedisConnectionException( + error, + instance, + instance.name, + ); + this.instancesAnalyticsService.sendConnectionFailedEvent(instance, exception); + throw exception; + } + } + + /** + * Get redis database overview + * + * @param instanceId + */ + public async getOverview(instanceId: string): Promise { + this.logger.log(`Getting database ${instanceId} overview`); + + const tool = AppTool.Common; + + let client = this.redisService.getClientInstance({ instanceId, tool })?.client; + if (!client || !this.redisService.isClientConnected(client)) { + client = await this.connectToInstance(instanceId, tool, true); + } + return this.overviewService.getOverview(instanceId, client); + } + + public async getInfo( + instanceId: string, + tool = AppTool.Common, + storeClientInstance = false, + ): Promise { + let info: RedisDatabaseInfoResponse; + this.logger.log(`Getting redis info. id: ${instanceId}`); + + let client = this.redisService.getClientInstance({ instanceId, tool })?.client; + if (!client || !this.redisService.isClientConnected(client)) { + client = await this.connectToInstance(instanceId, tool, storeClientInstance); + info = await this.redisConfBusinessService.getRedisGeneralInfo(client); + if (!storeClientInstance) { + await client.disconnect(); + } + } else { + info = await this.redisConfBusinessService.getRedisGeneralInfo(client); + } + return info; + } + + async rename( + id: string, + newName: string, + ): Promise { + this.logger.log(`Rename database instance. id: ${id}`); + const { name: oldName } = await this.databasesProvider.getOneById(id, true); + + try { + await this.databasesProvider.patch(id, { name: newName }); + this.logger.log('Succeed to rename database instance.'); + return { oldName, newName }; + } catch (error) { + this.logger.error(`Failed to rename database instance ${id}`, error); + throw new InternalServerErrorException(); + } + } + + public async addRedisEnterpriseDatabases( + connectionDetails: ClusterConnectionDetailsDto, + uids: number[], + ): Promise { + this.logger.log('Adding Redis Enterprise databases.'); + let result: AddRedisEnterpriseDatabaseResponse[]; + try { + const databases: RedisEnterpriseDatabase[] = await this.redisEnterpriseService.getDatabases( + connectionDetails, + ); + result = await Promise.all( + uids.map( + async (uid): Promise => { + const database = databases.find( + (db: RedisEnterpriseDatabase) => db.uid === uid, + ); + if (!database) { + const exception = new NotFoundException(); + return { + uid, + status: AddRedisDatabaseStatus.Fail, + message: exception.message, + error: exception?.getResponse(), + }; + } + try { + const { + port, name, dnsName, password, + } = database; + const host = connectionDetails.host === 'localhost' ? 'localhost' : dnsName; + delete database.password; + await this.addDatabase({ + host, + port, + name, + nameFromProvider: name, + password, + provider: HostingProvider.RE_CLUSTER, + }); + return { + uid, + status: AddRedisDatabaseStatus.Success, + message: 'Added', + databaseDetails: database, + }; + } catch (error) { + return { + uid, + status: AddRedisDatabaseStatus.Fail, + message: error.message, + databaseDetails: database, + error: error?.response, + }; + } + }, + ), + ); + } catch (error) { + this.logger.error('Failed to add Redis Enterprise databases', error); + throw error; + } + return result; + } + + public async addRedisCloudDatabases( + auth: CloudAuthDto, + addDatabasesDto: AddRedisCloudDatabaseDto[], + ): Promise { + this.logger.log('Adding Redis Cloud databases.'); + let result: AddRedisCloudDatabaseResponse[]; + try { + result = await Promise.all( + addDatabasesDto.map( + async ( + dto: AddRedisCloudDatabaseDto, + ): Promise => { + const database = await this.redisCloudService.getDatabase({ + ...auth, + ...dto, + }); + try { + const { + publicEndpoint, name, password, status, + } = database; + if (status !== RedisEnterpriseDatabaseStatus.Active) { + const exception = new ServiceUnavailableException(ERROR_MESSAGES.DATABASE_IS_INACTIVE); + return { + ...dto, + status: AddRedisDatabaseStatus.Fail, + message: exception.message, + error: exception?.getResponse(), + databaseDetails: database, + }; + } + const [host, port] = publicEndpoint.split(':'); + await this.addDatabase({ + host, + port: parseInt(port, 10), + name, + nameFromProvider: name, + password, + provider: HostingProvider.RE_CLOUD, + }); + return { + ...dto, + status: AddRedisDatabaseStatus.Success, + message: 'Added', + databaseDetails: database, + }; + } catch (error) { + return { + ...dto, + status: AddRedisDatabaseStatus.Fail, + message: error.message, + error: error?.response, + databaseDetails: database, + }; + } + }, + ), + ); + } catch (error) { + this.logger.error('Failed to add Redis Cloud databases.', error); + throw error; + } + return result; + } + + public async addSentinelMasters( + dto: AddSentinelMastersDto, + ): Promise { + this.logger.log('Adding Sentinel masters.'); + const result: AddSentinelMasterResponse[] = []; + const { masters, ...connectionOptions } = dto; + try { + const client = await this.redisService.createStandaloneClient( + connectionOptions, + AppTool.Common, + false, + ); + const isOssSentinel = await this.redisConfBusinessService.checkSentinelConnection( + client, + ); + if (!isOssSentinel) { + await client.disconnect(); + this.logger.error( + `Failed to add Sentinel masters. ${ERROR_MESSAGES.WRONG_DATABASE_TYPE}.`, + ); + const exception = new BadRequestException( + ERROR_MESSAGES.WRONG_DATABASE_TYPE, + ); + this.instancesAnalyticsService.sendInstanceAddFailedEvent(exception); + return Promise.reject(exception); + } + + await Promise.all(masters.map(async (master) => { + const { + alias, name, password, username, db, + } = master; + const addedMasterGroup = find(result, { + status: AddRedisDatabaseStatus.Success, + }); + try { + const databaseEntity = await this.createSentinelDatabaseEntity( + { + ...connectionOptions, + tls: addedMasterGroup?.instance?.tls || connectionOptions.tls, + name: alias, + db, + sentinelMaster: { + name, + username, + password, + }, + }, + client, + ); + const instance = convertEntityToDto( + await this.databasesProvider.save(databaseEntity), + ); + const redisInfo = await this.getInfo(instance.id); + this.instancesAnalyticsService.sendInstanceAddedEvent( + instance, + redisInfo, + ); + result.push({ + id: instance.id, + name, + instance, + status: AddRedisDatabaseStatus.Success, + message: 'Added', + }); + } catch (error) { + this.instancesAnalyticsService.sendInstanceAddFailedEvent(error); + result.push({ + name, + status: AddRedisDatabaseStatus.Fail, + message: error?.response?.message, + error: error?.response, + }); + } + })); + + await client.disconnect(); + return result.map( + (item: AddSentinelMasterResponse): AddSentinelMasterResponse => omit(item, 'instance'), + ); + } catch (error) { + this.logger.error('Failed to add Sentinel masters.', error); + const exception = getRedisConnectionException(error, connectionOptions); + this.instancesAnalyticsService.sendInstanceAddFailedEvent(exception); + throw exception; + } + } + + public async createDatabaseEntity( + databaseDto: AddDatabaseInstanceDto, + storeCert: boolean = true, + ): Promise { + const { tls, provider, ...rest } = databaseDto; + const database: DatabaseInstanceEntity = this.instanceRepository.create({ + username: null, + password: null, + provider: provider || getHostingProvider(rest.host), + ...rest, + }); + database.tls = !!tls; + if (storeCert && database.tls) { + database.verifyServerCert = tls.verifyServerCert; + if (tls.newCaCert) { + database.caCert = await this.caCertBusinessService.create( + tls.newCaCert, + ); + } else if (tls.caCertId) { + database.caCert = await this.caCertBusinessService.getOneById( + tls.caCertId, + ); + } + if (tls.newClientCertPair) { + database.clientCert = await this.clientCertBusinessService.create( + tls.newClientCertPair, + ); + } else if (tls.clientCertPairId) { + database.clientCert = await this.clientCertBusinessService.getOneById( + tls.clientCertPairId, + ); + } + } else { + database.verifyServerCert = false; + database.caCert = null; + database.clientCert = null; + } + return database; + } + + async createClusterDatabaseEntity( + databaseDto: AddDatabaseInstanceDto, + client: IORedis.Redis, + ): Promise { + this.logger.log('Adding oss cluster.'); + try { + const nodes = ( + await this.redisConfBusinessService.getRedisClusterNodes(client) + ).filter( + (node) => node.linkState === RedisClusterNodeLinkState.Connected, + ); + const nodeAddresses = nodes.map((node) => ({ + host: node.host, + port: node.port, + })); + const clusterClient = await this.redisService.createClusterClient( + databaseDto, + nodeAddresses, + ); + const primaryNodeOptions = clusterClient.nodes('master')[0].options; + const databaseEntity = await this.createDatabaseEntity({ + ...databaseDto, + host: primaryNodeOptions.host, + port: primaryNodeOptions.port, + }); + databaseEntity.connectionType = ConnectionType.CLUSTER; + databaseEntity.nodes = JSON.stringify(nodeAddresses); + await clusterClient.disconnect(); + return databaseEntity; + } catch (error) { + this.logger.error('Failed to add oss cluster.', error); + throw catchRedisConnectionError(error, databaseDto); + } + } + + async createSentinelDatabaseEntity( + databaseDto: AddDatabaseInstanceDto, + client: IORedis.Redis, + ): Promise { + this.logger.log('Adding oss sentinel.'); + try { + const { sentinelMaster } = databaseDto; + const masters = await this.redisSentinelService.getMasters(client); + const selectedMaster = masters.find( + (master) => master.name === sentinelMaster.name, + ); + if (!selectedMaster) { + return Promise.reject( + new NotFoundException(ERROR_MESSAGES.MASTER_GROUP_NOT_EXIST), + ); + } + const sentinelClient = await this.redisService.createSentinelClient( + databaseDto, + selectedMaster.endpoints, + AppTool.Common, + ); + const databaseEntity = await this.createDatabaseEntity({ + ...databaseDto, + }); + databaseEntity.connectionType = ConnectionType.SENTINEL; + databaseEntity.nodes = JSON.stringify(selectedMaster.endpoints); + databaseEntity.sentinelMasterName = sentinelMaster.name; + databaseEntity.sentinelMasterUsername = sentinelMaster.username; + databaseEntity.sentinelMasterPassword = sentinelMaster.password; + await sentinelClient.disconnect(); + return databaseEntity; + } catch (error) { + this.logger.error('Failed to add oss sentinel.', error); + throw catchRedisConnectionError(error, databaseDto); + } + } + + /** + * Get whitelisted commands available for plugins for particular database + */ + async getPluginCommands( + instanceId: string, + tool = AppTool.Browser, + ): Promise { + let client = this.redisService.getClientInstance({ instanceId, tool })?.client; + if (!client || !this.redisService.isClientConnected(client)) { + client = await this.connectToInstance(instanceId, tool, true); + } + + return await this.redisConfBusinessService.getPluginWhiteListCommands(client); + } +} diff --git a/redisinsight/api/src/modules/shared/services/instances-business/overview.service.spec.ts b/redisinsight/api/src/modules/shared/services/instances-business/overview.service.spec.ts new file mode 100644 index 0000000000..fb60bdbe54 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/instances-business/overview.service.spec.ts @@ -0,0 +1,249 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import * as Redis from 'ioredis'; +import { when } from 'jest-when'; +import { + mockStandaloneDatabaseEntity, + mockStandaloneRedisInfoReply, +} from 'src/__mocks__'; +import { OverviewService } from 'src/modules/shared/services/instances-business/overview.service'; +import { DatabaseOverview } from 'src/modules/instances/dto/database-overview.dto'; + +const mockClient = Object.create(Redis.prototype); +mockClient.options = { + ...mockClient.options, + host: 'localhost', + port: 6379, +}; +const mockCluster = Object.create(Redis.Cluster.prototype); + +const mockServerInfo = { + redis_version: '6.2.4', + uptime_in_seconds: '1', +}; +const mockReplicationInfo = { + role: 'master', +}; +const mockMemoryInfo = { + used_memory: '1', +}; +const mockStatsInfo = { + instantaneous_ops_per_sec: '1', + instantaneous_input_kbps: '1', + instantaneous_output_kbps: '1', +}; +const mockCpu = { + used_cpu_sys: '1', + used_cpu_user: '1', +}; +const mockClientsInfo = { + connected_clients: '1', +}; +const mockKeyspace = { + db0: 'keys=1,expires=0,avg_ttl=0', + db1: 'keys=0,expires=0,avg_ttl=0', + db2: 'keys=0,expires=0,avg_ttl=0', +}; +const mockNodeInfo = { + host: 'localhost', + port: 6379, + server: mockServerInfo, + replication: mockReplicationInfo, + stats: mockStatsInfo, + memory: mockMemoryInfo, + cpu: mockCpu, + clients: mockClientsInfo, + keyspace: mockKeyspace, +}; + +const databaseId = mockStandaloneDatabaseEntity.id; +export const mockDatabaseOverview: DatabaseOverview = { + version: mockServerInfo.redis_version, + usedMemory: 1, + totalKeys: 1, + connectedClients: 1, + opsPerSecond: 1, + networkInKbps: 1, + networkOutKbps: 1, + cpuUsagePercentage: null, +}; + +describe('OverviewService', () => { + let service: OverviewService; + let spyGetNodeInfo; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [OverviewService], + }).compile(); + + service = await module.get(OverviewService); + spyGetNodeInfo = jest.spyOn(service, 'getNodeInfo'); + mockClient.send_command = jest.fn(); + }); + + describe('getOverview', () => { + describe('Standalone', () => { + it('should return proper overview', async () => { + when(mockClient.send_command) + .calledWith('info') + .mockResolvedValue(mockStandaloneRedisInfoReply); + + const result = await service.getOverview(databaseId, mockClient); + + expect(result).toEqual({ + ...mockDatabaseOverview, + version: '6.0.5', + connectedClients: 1, + totalKeys: 1, + usedMemory: 1000000, + opsPerSecond: 0, + networkInKbps: 0, + networkOutKbps: 0, + }); + }); + it('check for cpu on second attempt', async () => { + spyGetNodeInfo.mockResolvedValueOnce(mockNodeInfo); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + server: { + ...mockNodeInfo.server, + uptime_in_seconds: '3', + }, + cpu: { + ...mockNodeInfo.cpu, + used_cpu_sys: '1.5', + used_cpu_user: '1.5', + }, + }); + + expect(await service.getOverview(databaseId, mockClient)).toEqual({ + ...mockDatabaseOverview, + }); + + expect(await service.getOverview(databaseId, mockClient)).toEqual({ + ...mockDatabaseOverview, + cpuUsagePercentage: 50, + }); + }); + it('check for cpu max value = 100', async () => { + spyGetNodeInfo.mockResolvedValueOnce(mockNodeInfo); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + server: { + ...mockNodeInfo.server, + uptime_in_seconds: '2', + }, + cpu: { + ...mockNodeInfo.cpu, + used_cpu_sys: '1.51', + used_cpu_user: '1.50002', + }, + }); + + expect(await service.getOverview(databaseId, mockClient)).toEqual({ + ...mockDatabaseOverview, + }); + + expect(await service.getOverview(databaseId, mockClient)).toEqual({ + ...mockDatabaseOverview, + cpuUsagePercentage: 100, + }); + }); + }); + describe('Cluster', () => { + it('Should calculate overview and ignore replica where needed', async () => { + mockCluster.nodes = jest.fn() + .mockReturnValue(new Array(6).fill(Promise.resolve())); + + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12001, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12002, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12003, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12004, + replication: { role: 'slave' }, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12005, + replication: { role: 'slave' }, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12006, + replication: { role: 'slave' }, + }); + + expect(await service.getOverview(databaseId, mockCluster)).toEqual({ + ...mockDatabaseOverview, + connectedClients: 1, + totalKeys: 3, + usedMemory: 3, + networkInKbps: 6, + networkOutKbps: 6, + opsPerSecond: 6, + cpuUsagePercentage: null, + }); + + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12001, + server: { ...mockNodeInfo.server, uptime_in_seconds: '3' }, + cpu: { ...mockNodeInfo.cpu, used_cpu_sys: '1.5', used_cpu_user: '1.5' }, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12002, + server: { ...mockNodeInfo.server, uptime_in_seconds: '3' }, + cpu: { ...mockNodeInfo.cpu, used_cpu_sys: '1.5', used_cpu_user: '1.5' }, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12003, + server: { ...mockNodeInfo.server, uptime_in_seconds: '3' }, + cpu: { ...mockNodeInfo.cpu, used_cpu_sys: '1.5', used_cpu_user: '1.5' }, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12004, + replication: { role: 'slave' }, + server: { ...mockNodeInfo.server, uptime_in_seconds: '3' }, + cpu: { ...mockNodeInfo.cpu, used_cpu_sys: '1.5', used_cpu_user: '1.5' }, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12005, + replication: { role: 'slave' }, + server: { ...mockNodeInfo.server, uptime_in_seconds: '3' }, + cpu: { ...mockNodeInfo.cpu, used_cpu_sys: '1.5', used_cpu_user: '1.5' }, + }); + spyGetNodeInfo.mockResolvedValueOnce({ + ...mockNodeInfo, + port: 12006, + replication: { role: 'slave' }, + server: { ...mockNodeInfo.server, uptime_in_seconds: '3' }, + cpu: { ...mockNodeInfo.cpu, used_cpu_sys: '1.5', used_cpu_user: '1.5' }, + }); + + expect(await service.getOverview(databaseId, mockCluster)).toEqual({ + ...mockDatabaseOverview, + connectedClients: 1, + totalKeys: 3, + usedMemory: 3, + networkInKbps: 6, + networkOutKbps: 6, + opsPerSecond: 6, + cpuUsagePercentage: 300, + }); + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/instances-business/overview.service.ts b/redisinsight/api/src/modules/shared/services/instances-business/overview.service.ts new file mode 100644 index 0000000000..d09a601783 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/instances-business/overview.service.ts @@ -0,0 +1,244 @@ +import { Injectable } from '@nestjs/common'; +import IORedis from 'ioredis'; +import { + get, + filter, + map, + keyBy, + sum, + sumBy, +} from 'lodash'; +import { + convertBulkStringsToObject, + convertRedisInfoReplyToObject, +} from 'src/utils'; +import { DatabaseOverview } from 'src/modules/instances/dto/database-overview.dto'; + +@Injectable() +export class OverviewService { + private previousCpuStats = new Map(); + + /** + * Calculates redis database metrics based on connection type (eg Cluster or Standalone) + * @param id + * @param client + */ + async getOverview( + id: string, + client: IORedis.Redis | IORedis.Cluster, + ): Promise { + let nodesInfo = []; + if (client instanceof IORedis.Cluster) { + nodesInfo = await this.getNodesInfo(client); + } else { + nodesInfo = [await this.getNodeInfo(client)]; + } + + return { + version: this.getVersion(nodesInfo), + totalKeys: this.calculateTotalKeys(nodesInfo), + usedMemory: this.calculateUsedMemory(nodesInfo), + connectedClients: this.calculateConnectedClients(nodesInfo), + opsPerSecond: this.calculateOpsPerSec(nodesInfo), + networkInKbps: this.calculateNetworkIn(nodesInfo), + networkOutKbps: this.calculateNetworkOut(nodesInfo), + cpuUsagePercentage: this.calculateCpuUsage(id, nodesInfo), + }; + } + + /** + * Get redis info (executing "info" command) for node + * @param client + * @private + */ + private async getNodeInfo(client: IORedis.Redis) { + const { host, port } = client.options; + return { + ...convertRedisInfoReplyToObject( + await client.send_command('info'), + ), + host, + port, + }; + } + + /** + * Get info for each node in cluster + * @param client + * @private + */ + private async getNodesInfo(client: IORedis.Cluster) { + return Promise.all(client.nodes('all').map(this.getNodeInfo)); + } + + /** + * Get median value from array of numbers + * Will return 0 when empty array received + * @param values + * @private + */ + private getMedianValue(values: number[]): number { + if (!values.length) { + return 0; + } + + values.sort((a, b) => a - b); + + const middleIndex = Math.floor(values.length / 2); + + // process odd array + if (values.length % 2) { + return values[middleIndex]; + } + + return (values[middleIndex - 1] + values[middleIndex]) / 2; + } + + /** + * Get redis version from the first chard in the list + * @param nodes + * @private + */ + private getVersion(nodes = []): string { + return get(nodes, [0, 'server', 'redis_version'], null); + } + + /** + * Sum of current ops per second (instantaneous_ops_per_sec) for all shards + * @param nodes + * @private + */ + private calculateOpsPerSec(nodes = []): number { + return sumBy(nodes, (node) => parseInt( + get(node, 'stats.instantaneous_ops_per_sec', 0), + 10, + )); + } + + /** + * Sum of current network input (instantaneous_input_kbps) for all shards + * @param nodes + * @private + */ + private calculateNetworkIn(nodes = []): number { + return sumBy(nodes, (node) => parseInt( + get(node, 'stats.instantaneous_input_kbps', 0), + 10, + )); + } + + /** + * Sum of current network output (instantaneous_output_kbps) for all shards + * @param nodes + * @private + */ + private calculateNetworkOut(nodes = []): number { + return sumBy(nodes, (node) => parseInt( + get(node, 'stats.instantaneous_output_kbps', 0), + 10, + )); + } + + /** + * Median of connected clients (connected_clients) to all shards + * @param nodes + * @private + */ + private calculateConnectedClients(nodes = []): number { + const clientsPerNode = map(nodes, (node) => parseInt(get(node, 'clients.connected_clients', 0), 10)); + return this.getMedianValue(clientsPerNode); + } + + /** + * Sum of used memory (used_memory) for primary shards + * @param nodes + * @private + */ + private calculateUsedMemory(nodes = []): number { + try { + const masterNodes = filter(nodes, (node) => get(node, 'replication.role') === 'master'); + + return sumBy(masterNodes, (node) => parseInt(get(node, 'memory.used_memory', 0), 10)); + } catch (e) { + return null; + } + } + + /** + * Sum of keys for primary shards + * In case when shard has multiple logical databases shard total keys = sum of all dbs keys + * @param nodes + * @private + */ + private calculateTotalKeys(nodes = []): number { + try { + const masterNodes = filter(nodes, (node) => get(node, 'replication.role') === 'master'); + return sumBy(masterNodes, (node) => sum( + map( + get(node, 'keyspace', {}), + (dbKeys): number => { + const { keys } = convertBulkStringsToObject(dbKeys, ',', '='); + return parseInt(keys, 10); + }, + ), + )); + } catch (e) { + return null; + } + } + + /** + * Calculates sum of cpu usage in percentage for all shards + * CPU% = ((used_cpu_sys_t2+used_cpu_user_t2)-(used_cpu_sys_t1+used_cpu_user_t1)) / (t2-t1) + * + * Example of calculation: + * Shard 1 CPU: 55% + * Shard 2 CPU: 15% + * Shard 3 CPU: 50% + * Total displayed: 120% (55%+15%+50%). + * @param id + * @param nodes + * @private + */ + private calculateCpuUsage(id: string, nodes = []): number { + const previousCpuStats = this.previousCpuStats.get(id); + + const currentCpuStats = keyBy(map(nodes, (node) => ({ + node: `${node.host}:${node.port}`, + cpuSys: parseFloat(get(node, 'cpu.used_cpu_sys')), + cpuUser: parseFloat(get(node, 'cpu.used_cpu_user')), + upTime: parseFloat(get(node, 'server.uptime_in_seconds')), + })), 'node'); + + this.previousCpuStats.set(id, currentCpuStats); + + // return null as it is impossible to calculate percentage without previous results + if (!previousCpuStats) { + return null; + } + return sum(map(currentCpuStats, (current) => { + const previous = previousCpuStats[current.node]; + if ( + !previous + || previous.upTime >= current.upTime // in case when server was restarted or too often requests + ) { + return 0; + } + + const currentUsage = current.cpuUser + current.cpuSys; + const previousUsage = previous.cpuUser + previous.cpuSys; + const timeDelta = current.upTime - previous.upTime; + + const usage = ((currentUsage - previousUsage) / timeDelta) * 100; + + // let's return 0 in case of incorrect data retrieved from redis + if (usage < 0) { + return 0; + } + + // sometimes it is possible to have CPU usage greater than 100% + // it could happen because we are getting database up time in seconds when CPU usage time in milliseconds + return usage > 100 ? 100 : usage; + })); + } +} diff --git a/redisinsight/api/src/modules/shared/services/redis-cloud-business/redis-cloud-business.service.spec.ts b/redisinsight/api/src/modules/shared/services/redis-cloud-business/redis-cloud-business.service.spec.ts new file mode 100644 index 0000000000..fd353dad82 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/redis-cloud-business/redis-cloud-business.service.spec.ts @@ -0,0 +1,489 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import axios, { AxiosError } from 'axios'; +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { mockAutodiscoveryAnalyticsService } from 'src/__mocks__'; +import { IRedisCloudAccount } from 'src/modules/redis-enterprise/models/redis-cloud-account'; +import { + CloudAuthDto, + GetCloudAccountShortInfoResponse, + RedisCloudDatabase, + GetRedisCloudSubscriptionResponse, +} from 'src/modules/redis-enterprise/dto/cloud.dto'; +import { + IRedisCloudSubscription, + RedisCloudSubscriptionStatus, +} from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; +import { + IRedisCloudDatabase, + IRedisCloudDatabasesResponse, + RedisCloudDatabaseProtocol, +} from 'src/modules/redis-enterprise/models/redis-cloud-database'; +import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; +import { RedisCloudBusinessService } from './redis-cloud-business.service'; +import { AutodiscoveryAnalyticsService } from '../autodiscovery-analytics.service/autodiscovery-analytics.service'; + +const mockedAxios = axios as jest.Mocked; +jest.mock('axios'); +mockedAxios.create = jest.fn(() => mockedAxios); + +const mockCloudAuthDto: CloudAuthDto = { + apiKey: 'api_key', + apiSecretKey: 'api_secret_key', +}; +const mockRedisCloudAccount: IRedisCloudAccount = { + id: 40131, + name: 'Redis Labs', + createdTimestamp: '2018-12-23T15:15:31Z', + updatedTimestamp: '2020-06-03T13:16:59Z', + key: { + name: 'QA-HashedIn-Test-API-Key-2', + accountId: 40131, + accountName: 'Redis Labs', + allowedSourceIps: ['0.0.0.0/0'], + createdTimestamp: '2020-04-06T09:22:38Z', + owner: { + name: 'Cloud Account', + email: 'cloud.account@redislabs.com', + }, + httpSourceIp: '198.141.36.229', + }, +}; + +const mockRedisCloudSubscription: IRedisCloudSubscription = { + id: 108353, + name: 'external CA', + status: RedisCloudSubscriptionStatus.Active, + paymentMethodId: 8240, + memoryStorage: 'ram', + storageEncryption: false, + numberOfDatabases: 7, + subscriptionPricing: [ + { + type: 'Shards', + typeDetails: 'high-throughput', + quantity: 2, + quantityMeasurement: 'shards', + pricePerUnit: 0.124, + priceCurrency: 'USD', + pricePeriod: 'hour', + }, + ], + cloudDetails: [ + { + provider: 'AWS', + cloudAccountId: 16424, + totalSizeInGb: 0.0323, + regions: [ + { + region: 'us-east-1', + networking: [ + { + deploymentCIDR: '10.0.0.0/24', + subnetId: 'subnet-0a2dd5829daf83024', + }, + ], + preferredAvailabilityZones: ['us-east-1a'], + multipleAvailabilityZones: false, + }, + ], + }, + ], +}; + +const mockRedisCloudDatabase: IRedisCloudDatabase = { + databaseId: 50859754, + name: 'bdb', + protocol: RedisCloudDatabaseProtocol.Redis, + provider: 'GCP', + region: 'us-central1', + redisVersionCompliance: '5.0.5', + status: RedisEnterpriseDatabaseStatus.Active, + memoryLimitInGb: 1.0, + memoryUsedInMb: 6.0, + memoryStorage: 'ram', + supportOSSClusterApi: false, + dataPersistence: 'none', + replication: true, + dataEvictionPolicy: 'volatile-lru', + throughputMeasurement: { + by: 'operations-per-second', + value: 25000, + }, + activatedOn: '2019-12-31T09:38:41Z', + lastModified: '2019-12-31T09:38:41Z', + publicEndpoint: + 'redis-14621.c34097.us-central1-mz.gcp.qa-cloud.rlrcp.com:14621', + privateEndpoint: + 'redis-14621.internal.c34097.us-central1-mz.gcp.qa-cloud.rlrcp.com:14621', + replicaOf: { + endpoints: [ + 'redis-19669.c9244.us-central1-mz.gcp.cloud.rlrcp.com:19669', + 'redis-14074.c9243.us-central1-mz.gcp.cloud.rlrcp.com:14074', + ], + }, + clustering: { + numberOfShards: 1, + regexRules: [], + hashingPolicy: 'standard', + }, + security: { + sslClientAuthentication: false, + sourceIps: ['0.0.0.0/0'], + }, + modules: [ + { + id: 1, + name: 'ReJSON', + version: 'v10007', + }, + ], + alerts: [], +}; + +const mockUnauthenticatedErrorMessage = 'Request failed with status code 401'; +const mockApiUnauthenticatedResponse = { + message: mockUnauthenticatedErrorMessage, + response: { + status: 401, + }, +}; + +const mockParsedRedisCloudDatabase: RedisCloudDatabase = { + subscriptionId: mockRedisCloudSubscription.id, + databaseId: mockRedisCloudDatabase.databaseId, + name: mockRedisCloudDatabase.name, + publicEndpoint: mockRedisCloudDatabase.publicEndpoint, + status: mockRedisCloudDatabase.status, + sslClientAuthentication: false, + password: undefined, + modules: ['ReJSON'], + options: { + enabledBackup: false, + enabledClustering: false, + enabledDataPersistence: false, + enabledRedisFlash: false, + enabledReplication: true, + isReplicaDestination: true, + persistencePolicy: 'none', + }, +}; + +const mockRedisCloudDatabasesResponse: IRedisCloudDatabasesResponse = { + accountId: 40131, + subscription: [ + { + subscriptionId: 86070, + numberOfDatabases: 1, + databases: [mockRedisCloudDatabase], + }, + ], +}; + +describe('RedisCloudBusinessService', () => { + let service: RedisCloudBusinessService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisCloudBusinessService, + { + provide: AutodiscoveryAnalyticsService, + useFactory: mockAutodiscoveryAnalyticsService, + }, + ], + }).compile(); + + service = module.get(RedisCloudBusinessService); + }); + + describe('getAccount', () => { + let parseCloudAccountResponse: jest.SpyInstance< + GetCloudAccountShortInfoResponse, + [account: IRedisCloudAccount] + >; + beforeEach(() => { + parseCloudAccountResponse = jest.spyOn( + service, + 'parseCloudAccountResponse', + ); + }); + + it('successfully get Redis Enterprise Cloud account', async () => { + const response = { + status: 200, + data: { account: mockRedisCloudAccount }, + }; + mockedAxios.get.mockResolvedValue(response); + + await expect(service.getAccount(mockCloudAuthDto)).resolves.not.toThrow(); + expect(mockedAxios.get).toHaveBeenCalled(); + expect(parseCloudAccountResponse).toHaveBeenCalledWith( + mockRedisCloudAccount, + ); + }); + it('Should throw Forbidden exception', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect(service.getAccount(mockCloudAuthDto)).rejects.toThrow( + ForbiddenException, + ); + }); + }); + + describe('getSubscriptions', () => { + let parseCloudSubscriptionsResponse: jest.SpyInstance< + GetRedisCloudSubscriptionResponse[], + [subscriptions: IRedisCloudSubscription[]] + >; + beforeEach(() => { + parseCloudSubscriptionsResponse = jest.spyOn( + service, + 'parseCloudSubscriptionsResponse', + ); + }); + + it('successfully get Redis Enterprise Cloud subscriptions', async () => { + const response = { + status: 200, + data: { subscriptions: [mockRedisCloudSubscription] }, + }; + mockedAxios.get.mockResolvedValue(response); + + await expect( + service.getSubscriptions(mockCloudAuthDto), + ).resolves.not.toThrow(); + expect(mockedAxios.get).toHaveBeenCalled(); + expect(parseCloudSubscriptionsResponse).toHaveBeenCalledWith([ + mockRedisCloudSubscription, + ]); + }); + it('should throw forbidden error when get subscriptions', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect(service.getSubscriptions(mockCloudAuthDto)).rejects.toThrow( + ForbiddenException, + ); + }); + }); + + describe('getDatabasesInSubscription', () => { + let parseCloudDatabasesResponse: jest.SpyInstance< + RedisCloudDatabase[], + [response: IRedisCloudDatabasesResponse] + >; + beforeEach(() => { + parseCloudDatabasesResponse = jest.spyOn( + service, + 'parseCloudDatabasesInSubscriptionResponse', + ); + }); + + it('successfully get Redis Enterprise Cloud databases', async () => { + const response = { + status: 200, + data: mockRedisCloudDatabasesResponse, + }; + mockedAxios.get.mockResolvedValue(response); + + await expect( + service.getDatabasesInSubscription({ + ...mockCloudAuthDto, + subscriptionId: 86070, + }), + ).resolves.not.toThrow(); + expect(mockedAxios.get).toHaveBeenCalled(); + expect(parseCloudDatabasesResponse).toHaveBeenCalledWith( + mockRedisCloudDatabasesResponse, + ); + }); + it('the user could not be authenticated', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect( + service.getDatabasesInSubscription({ + ...mockCloudAuthDto, + subscriptionId: 86070, + }), + ).rejects.toThrow(ForbiddenException); + }); + it('subscription not found', async () => { + const subscriptionId = mockRedisCloudSubscription.id; + const apiResponse = { + message: `Subscription ${subscriptionId} not found`, + response: { + status: 404, + }, + }; + mockedAxios.get.mockRejectedValue(apiResponse); + + await expect( + service.getDatabasesInSubscription({ + ...mockCloudAuthDto, + subscriptionId, + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getDatabasesInMultipleSubscriptions', () => { + beforeEach(() => { + service.getDatabasesInSubscription = jest.fn().mockResolvedValue([]); + }); + it('should call getDatabasesInSubscription', async () => { + await service.getDatabasesInMultipleSubscriptions({ + ...mockCloudAuthDto, + subscriptionIds: [86070, 86071], + }); + + expect(service.getDatabasesInSubscription).toHaveBeenCalledTimes(2); + }); + it('should not call getDatabasesInSubscription for duplicated ids', async () => { + await service.getDatabasesInMultipleSubscriptions({ + ...mockCloudAuthDto, + subscriptionIds: [86070, 86070, 86071], + }); + + expect(service.getDatabasesInSubscription).toHaveBeenCalledTimes(2); + }); + it('subscription not found', async () => { + service.getDatabasesInSubscription = jest + .fn() + .mockRejectedValue(new NotFoundException()); + + await expect( + service.getDatabasesInMultipleSubscriptions({ + ...mockCloudAuthDto, + subscriptionIds: [86070, 86071], + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getDatabase', () => { + let parseCloudDatabaseResponse: jest.SpyInstance< + RedisCloudDatabase, + [database: IRedisCloudDatabase, subscriptionId: number] + >; + const subscriptionId = mockRedisCloudSubscription.id; + const databaseId = mockRedisCloudSubscription.id; + beforeEach(() => { + parseCloudDatabaseResponse = jest.spyOn( + service, + 'parseCloudDatabaseResponse', + ); + }); + + it('successfully get database from Redis Cloud subscriptions', async () => { + const response = { + status: 200, + data: mockRedisCloudDatabase, + }; + mockedAxios.get.mockResolvedValue(response); + + await expect( + service.getDatabase({ + ...mockCloudAuthDto, + subscriptionId, + databaseId, + }), + ).resolves.not.toThrow(); + expect(mockedAxios.get).toHaveBeenCalled(); + expect(parseCloudDatabaseResponse).toHaveBeenCalledWith( + mockRedisCloudDatabase, + subscriptionId, + ); + }); + it('the user could not be authenticated', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect( + service.getDatabase({ + ...mockCloudAuthDto, + subscriptionId, + databaseId, + }), + ).rejects.toThrow(ForbiddenException); + }); + it('database not found', async () => { + const apiResponse = { + message: `Subscription ${subscriptionId} database ${databaseId} not found`, + response: { + status: 404, + }, + }; + mockedAxios.get.mockRejectedValue(apiResponse); + + await expect( + service.getDatabase({ + ...mockCloudAuthDto, + subscriptionId, + databaseId, + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('parseCloudDatabaseResponse', () => { + const subscriptionId = mockRedisCloudSubscription.id; + it('should return correct value', () => { + const result = service.parseCloudDatabaseResponse( + mockRedisCloudDatabase, + subscriptionId, + ); + + expect(result).toEqual(mockParsedRedisCloudDatabase); + }); + }); + + describe('_getApiError', () => { + const title = 'Failed to get databases in RE cloud subscription'; + const mockError: AxiosError = { + name: '', + message: mockUnauthenticatedErrorMessage, + isAxiosError: true, + config: null, + response: { + statusText: mockUnauthenticatedErrorMessage, + data: null, + headers: [], + config: null, + status: 401, + }, + toJSON: () => null, + }; + it('should throw ForbiddenException', async () => { + const result = service.getApiError(mockError, title); + + expect(result).toBeInstanceOf(ForbiddenException); + }); + it('should throw InternalServerErrorException from response', async () => { + const errorMessage = 'Request failed with status code 500'; + const error = { + ...mockError, + message: errorMessage, + response: { + ...mockError.response, + status: 500, + statusText: errorMessage, + }, + }; + const result = service.getApiError(error, title); + + expect(result).toBeInstanceOf(InternalServerErrorException); + }); + it('should throw InternalServerErrorException', async () => { + const error = { + ...mockError, + message: 'Request failed with status code 500', + response: undefined, + }; + const result = service.getApiError(error, title); + + expect(result).toBeInstanceOf(InternalServerErrorException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/redis-cloud-business/redis-cloud-business.service.ts b/redisinsight/api/src/modules/shared/services/redis-cloud-business/redis-cloud-business.service.ts new file mode 100644 index 0000000000..51c0efd8c7 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/redis-cloud-business/redis-cloud-business.service.ts @@ -0,0 +1,333 @@ +import { + ForbiddenException, + HttpException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import axios, { AxiosError, AxiosResponse } from 'axios'; +import { get, find, uniq } from 'lodash'; +import config from 'src/utils/config'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { IRedisCloudAccount } from 'src/modules/redis-enterprise/models/redis-cloud-account'; +import { + CloudAuthDto, + GetCloudAccountShortInfoResponse, + GetDatabaseInCloudSubscriptionDto, + GetDatabasesInCloudSubscriptionDto, + GetDatabasesInMultipleCloudSubscriptionsDto, + RedisCloudDatabase, + GetRedisCloudSubscriptionResponse, +} from 'src/modules/redis-enterprise/dto/cloud.dto'; +import { IRedisCloudSubscription } from 'src/modules/redis-enterprise/models/redis-cloud-subscriptions'; +import { + IRedisCloudDatabase, + IRedisCloudDatabaseModule, + IRedisCloudDatabasesResponse, + RedisPersistencePolicy, + RedisCloudDatabaseProtocol, + RedisCloudMemoryStorage, +} from 'src/modules/redis-enterprise/models/redis-cloud-database'; +import { convertRECloudModuleName } from 'src/modules/redis-enterprise/utils/redis-cloud-converter'; +import { AutodiscoveryAnalyticsService } from '../autodiscovery-analytics.service/autodiscovery-analytics.service'; + +@Injectable() +export class RedisCloudBusinessService { + private logger = new Logger('RedisCloudBusinessService'); + + private config = config.get('redis_cloud'); + + private api = axios.create(); + + constructor(private autodiscoveryAnalyticsService: AutodiscoveryAnalyticsService) {} + + async getAccount( + dto: CloudAuthDto, + ): Promise { + this.logger.log('Getting RE cloud account.'); + const { apiKey, apiSecretKey } = dto; + try { + const { + data: { account }, + }: AxiosResponse = await this.api.get(`${this.config.url}`, { + headers: this.getAuthHeaders(apiKey, apiSecretKey), + }); + this.logger.log('Succeed to get RE cloud account.'); + + return this.parseCloudAccountResponse(account); + } catch (error) { + throw this.getApiError(error, 'Failed to get RE cloud account'); + } + } + + async getSubscriptions( + dto: CloudAuthDto, + ): Promise { + this.logger.log('Getting RE cloud subscriptions.'); + const { apiKey, apiSecretKey } = dto; + try { + const { + data: { subscriptions }, + }: AxiosResponse = await this.api.get( + `${this.config.url}/subscriptions`, + { + headers: this.getAuthHeaders(apiKey, apiSecretKey), + }, + ); + this.logger.log('Succeed to get RE cloud subscriptions.'); + const result = this.parseCloudSubscriptionsResponse(subscriptions); + this.autodiscoveryAnalyticsService.sendGetRECloudSubsSucceedEvent(result); + return result; + } catch (error) { + const exception = this.getApiError(error, 'Failed to get RE cloud subscriptions'); + this.autodiscoveryAnalyticsService.sendGetRECloudSubsFailedEvent(exception); + throw exception; + } + } + + async getDatabasesInSubscription( + dto: GetDatabasesInCloudSubscriptionDto, + ): Promise { + const { apiKey, apiSecretKey, subscriptionId } = dto; + this.logger.log( + `Getting databases in RE cloud subscription. subscription id: ${subscriptionId}`, + ); + try { + const { data }: AxiosResponse = await this.api.get( + `${this.config.url}/subscriptions/${subscriptionId}/databases`, + { + headers: this.getAuthHeaders(apiKey, apiSecretKey), + }, + ); + this.logger.log('Succeed to get databases in RE cloud subscription.'); + return this.parseCloudDatabasesInSubscriptionResponse(data); + } catch (error) { + const { response } = error; + let exception: HttpException; + if (response?.status === 404) { + const message = `Subscription ${subscriptionId} not found`; + this.logger.error( + `Failed to get databases in RE cloud subscription. ${message}.`, + ); + exception = new NotFoundException(message); + } else { + exception = this.getApiError( + error, + 'Failed to get databases in RE cloud subscription', + ); + } + throw exception; + } + } + + async getDatabase( + dto: GetDatabaseInCloudSubscriptionDto, + ): Promise { + const { + apiKey, apiSecretKey, subscriptionId, databaseId, + } = dto; + this.logger.log( + `Getting database in RE cloud subscription. subscription id: ${subscriptionId}, database id: ${databaseId}`, + ); + try { + const { data }: AxiosResponse = await this.api.get( + `${this.config.url}/subscriptions/${subscriptionId}/databases/${databaseId}`, + { + headers: this.getAuthHeaders(apiKey, apiSecretKey), + }, + ); + this.logger.log('Succeed to get databases in RE cloud subscription.'); + return this.parseCloudDatabaseResponse(data, subscriptionId); + } catch (error) { + const { response } = error; + if (response?.status === 404) { + this.logger.error( + `Failed to get databases in RE cloud subscription. ${response?.data?.message}.`, + ); + throw new NotFoundException(response?.data?.message); + } + throw this.getApiError( + error, + 'Failed to get databases in RE cloud subscription', + ); + } + } + + async getDatabasesInMultipleSubscriptions( + dto: GetDatabasesInMultipleCloudSubscriptionsDto, + ): Promise { + const { apiKey, apiSecretKey } = dto; + const subscriptionIds = uniq(dto.subscriptionIds); + this.logger.log('Getting databases in RE cloud subscriptions.'); + let result = []; + try { + await Promise.all( + subscriptionIds.map(async (subscriptionId: number) => { + const databases = await this.getDatabasesInSubscription({ + apiKey, + apiSecretKey, + subscriptionId, + }); + result = [...result, ...databases]; + }), + ); + this.autodiscoveryAnalyticsService.sendGetRECloudDbsSucceedEvent(result); + return result; + } catch (exception) { + this.autodiscoveryAnalyticsService.sendGetRECloudDbsFailedEvent(exception); + throw exception; + } + } + + parseCloudAccountResponse( + account: IRedisCloudAccount, + ): GetCloudAccountShortInfoResponse { + return { + accountId: account.id, + accountName: account.name, + ownerName: get(account, ['key', 'owner', 'name']), + ownerEmail: get(account, ['key', 'owner', 'email']), + }; + } + + parseCloudSubscriptionsResponse( + subscriptions: IRedisCloudSubscription[], + ): GetRedisCloudSubscriptionResponse[] { + const result: GetRedisCloudSubscriptionResponse[] = []; + if (subscriptions?.length) { + subscriptions.forEach((subscription: IRedisCloudSubscription): void => { + result.push({ + id: subscription.id, + name: subscription.name, + numberOfDatabases: subscription.numberOfDatabases, + status: subscription.status, + provider: get(subscription, ['cloudDetails', 0, 'provider']), + region: get(subscription, [ + 'cloudDetails', + 0, + 'regions', + 0, + 'region', + ]), + }); + }); + } + return result; + } + + parseCloudDatabasesInSubscriptionResponse( + response: IRedisCloudDatabasesResponse, + ): RedisCloudDatabase[] { + const subscription = response.subscription[0]; + const { subscriptionId, databases } = subscription; + let result: RedisCloudDatabase[] = []; + databases.forEach((database: IRedisCloudDatabase): void => { + // We do not send the databases which have 'memcached' as their protocol. + if (database.protocol === RedisCloudDatabaseProtocol.Redis) { + result.push(this.parseCloudDatabaseResponse(database, subscriptionId)); + } + }); + result = result.map((database) => ({ + ...database, + options: { + ...database.options, + isReplicaSource: !!this.findReplicasForDatabase( + databases, + database.databaseId, + ).length, + }, + })); + return result; + } + + parseCloudDatabaseResponse( + database: IRedisCloudDatabase, + subscriptionId: number, + ): RedisCloudDatabase { + const { + databaseId, name, publicEndpoint, status, security, + } = database; + return new RedisCloudDatabase({ + subscriptionId, + databaseId, + name, + publicEndpoint, + status, + password: security?.password, + sslClientAuthentication: security.sslClientAuthentication, + modules: database.modules + .map((module: IRedisCloudDatabaseModule) => convertRECloudModuleName(module.name)), + options: { + enabledDataPersistence: + database.dataPersistence !== RedisPersistencePolicy.None, + persistencePolicy: database.dataPersistence, + enabledRedisFlash: + database.memoryStorage === RedisCloudMemoryStorage.RamAndFlash, + enabledReplication: database.replication, + enabledBackup: !!database.periodicBackupPath, + enabledClustering: database.clustering.numberOfShards > 1, + isReplicaDestination: !!database.replicaOf, + }, + }); + } + + getApiError(error: AxiosError, errorTitle: string): HttpException { + const { response } = error; + if (response) { + if (response.status === 401 || response.status === 403) { + this.logger.error(`${errorTitle}. ${error.message}`); + return new ForbiddenException(ERROR_MESSAGES.REDIS_CLOUD_FORBIDDEN); + } + if (response.status === 500) { + this.logger.error(`${errorTitle}. ${error.message}`); + return new InternalServerErrorException( + ERROR_MESSAGES.SERVER_NOT_AVAILABLE, + ); + } + if (response.data) { + const { data } = response; + this.logger.error( + `${errorTitle} ${error.message}`, + JSON.stringify(data), + ); + return new InternalServerErrorException(data.description || data.error); + } + } + this.logger.error(`${errorTitle}. ${error.message}`); + return new InternalServerErrorException(ERROR_MESSAGES.SERVER_NOT_AVAILABLE); + } + + private getAuthHeaders(apiKey: string, apiSecretKey: string) { + return { + 'x-api-key': apiKey, + 'x-api-secret-key': apiSecretKey, + }; + } + + private findReplicasForDatabase( + databases: IRedisCloudDatabase[], + sourceDatabaseId: number, + ): IRedisCloudDatabase[] { + const sourceDatabase: IRedisCloudDatabase = find(databases, { + databaseId: sourceDatabaseId, + }); + if (!sourceDatabase) { + return []; + } + return databases.filter((replica: IRedisCloudDatabase): boolean => { + const endpoints = get(replica, ['replicaOf', 'endpoints']); + if ( + replica.databaseId === sourceDatabaseId + || !endpoints + || !endpoints.length + ) { + return false; + } + return endpoints.some((endpoint: string): boolean => ( + endpoint.includes(sourceDatabase.publicEndpoint) + || endpoint.includes(sourceDatabase.privateEndpoint) + )); + }); + } +} diff --git a/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.spec.ts b/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.spec.ts new file mode 100644 index 0000000000..36f3a6db70 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.spec.ts @@ -0,0 +1,311 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import axios from 'axios'; +import { RedisErrorCodes } from 'src/constants'; +import { mockAutodiscoveryAnalyticsService } from 'src/__mocks__'; +import { + IRedisEnterpriseDatabase, + IRedisEnterpriseEndpoint, + RedisEnterpriseDatabaseAofPolicy, + RedisEnterpriseDatabasePersistence, + RedisEnterpriseDatabaseStatus, +} from 'src/modules/redis-enterprise/models/redis-enterprise-database'; +import { RedisPersistencePolicy } from 'src/modules/redis-enterprise/models/redis-cloud-database'; +import { RedisEnterpriseBusinessService } from './redis-enterprise-business.service'; +import { AutodiscoveryAnalyticsService } from '../autodiscovery-analytics.service/autodiscovery-analytics.service'; +import { ClusterConnectionDetailsDto } from '../../../redis-enterprise/dto/cluster.dto'; + +const mockedAxios = axios as jest.Mocked; +jest.mock('axios'); +mockedAxios.create = jest.fn(() => mockedAxios); +const mockGetDatabasesDto: ClusterConnectionDetailsDto = { + host: 'localhost', + port: 9443, + username: 'admin@gmail.com', + password: 'adminpassword', +}; + +const mockREClusterDatabaseEndpoint: IRedisEnterpriseEndpoint = { + oss_cluster_api_preferred_ip_type: 'internal', + uid: '2:1', + addr_type: 'external', + dns_name: 'redis-11305.testcluster.local', + proxy_policy: 'single', + port: 11305, + addr: ['172.17.0.2'], +}; +const mockREClusterDatabase: IRedisEnterpriseDatabase = { + gradual_src_mode: 'disabled', + group_uid: 0, + memory_size: 107374182, + last_changed_time: '2021-02-15T11:56:40Z', + created_time: '2021-02-15T11:56:40Z', + skip_import_analyze: 'disabled', + rack_aware: false, + redis_version: '6.0', + oss_sharding: false, + shard_list: [2], + authentication_ssl_client_certs: [], + backup_progress: 0.0, + import_status: '', + hash_slots_policy: '16k', + dataset_import_sources: [], + roles_permissions: [], + replication: false, + authentication_admin_pass: '', + default_user: true, + name: 'basic', + crdt_causal_consistency: false, + authentication_sasl_pass: '', + import_failure_reason: '', + oss_cluster: false, + sync: 'disabled', + background_op: [{ status: 'idle' }], + authentication_ssl_crdt_certs: [], + port: 0, + crdt_guid: '', + version: '6.0.4', + email_alerts: false, + max_aof_load_time: 3600, + crdt_sources: [], + auto_upgrade: false, + backup_interval: 0, + slave_ha_priority: 0, + shards_placement: 'dense', + data_persistence: RedisEnterpriseDatabasePersistence.Disabled, + crdt_sync: 'disabled', + backup_status: '', + crdt: false, + crdt_replicas: '', + snapshot_policy: [], + backup: false, + gradual_sync_max_shards_per_source: 1, + backup_interval_offset: 0, + tls_mode: 'disabled', + replica_sync: 'disabled', + authentication_redis_pass: '', + implicit_shard_key: false, + max_aof_file_size: 322122547200, + bigstore: false, + max_connections: 0, + module_list: [], + eviction_policy: 'volatile-lru', + type: 'redis', + backup_history: 0, + sync_sources: [], + crdt_ghost_replica_ids: '', + replica_sources: [], + shard_block_foreign_keys: true, + enforce_client_authentication: 'enabled', + crdt_replica_id: 0, + crdt_config_version: 0, + proxy_policy: 'single', + aof_policy: RedisEnterpriseDatabaseAofPolicy.AofEveryOneSecond, + wait_command: true, + uid: 2, + authentication_sasl_uname: '', + backup_failure_reason: '', + bigstore_ram_size: 0, + shard_block_crossslot_keys: false, + acl: [], + slave_ha: false, + internal: false, + shards_count: 1, + shard_key_regex: [], + status: RedisEnterpriseDatabaseStatus.Active, + gradual_sync_mode: 'auto', + mkms: true, + gradual_src_max_sources: 1, + sharding: false, + oss_cluster_api_preferred_ip_type: 'internal', + ssl: false, + dns_address_master: '', + import_progress: 0.0, + endpoints: [mockREClusterDatabaseEndpoint], +}; +const mockREClusterDbsResponse: IRedisEnterpriseDatabase[] = [ + mockREClusterDatabase, +]; + +describe('ClusterBusinessService', () => { + let service; + let parseClusterDbsResponse; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AutodiscoveryAnalyticsService, + useFactory: mockAutodiscoveryAnalyticsService, + }, + RedisEnterpriseBusinessService, + ], + }).compile(); + + service = await module.get( + RedisEnterpriseBusinessService, + ); + parseClusterDbsResponse = jest.spyOn(service, 'parseClusterDbsResponse'); + }); + + describe('getDatabases', () => { + it('successfully get databases from RE cluster', async () => { + const response = { status: 200, data: mockREClusterDbsResponse }; + mockedAxios.get.mockResolvedValue(response); + + await expect( + service.getDatabases(mockGetDatabasesDto), + ).resolves.not.toThrow(); + expect(mockedAxios.get).toHaveBeenCalled(); + expect(parseClusterDbsResponse).toHaveBeenCalledWith( + mockREClusterDbsResponse, + ); + }); + it('the user could not be authenticated', async () => { + const apiResponse = { + message: 'Request failed with status code 401', + response: { + status: 401, + }, + }; + mockedAxios.get.mockRejectedValue(apiResponse); + + await expect(service.getDatabases(mockGetDatabasesDto)).rejects.toThrow( + ForbiddenException, + ); + }); + it('connection refused', async () => { + const apiResponse = { + code: RedisErrorCodes.ConnectionRefused, + message: 'connect ECONNREFUSED', + }; + mockedAxios.get.mockRejectedValue(apiResponse); + + await expect(service.getDatabases(mockGetDatabasesDto)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('getDatabaseExternalEndpoint', () => { + const externalEndpoint: IRedisEnterpriseEndpoint = mockREClusterDatabaseEndpoint; + const internalEndpoint: IRedisEnterpriseEndpoint = { + ...mockREClusterDatabaseEndpoint, + addr_type: 'internal', + }; + it('should return only one external endpoints', async () => { + const result = service.getDatabaseExternalEndpoint({ + ...mockREClusterDatabase, + endpoints: [externalEndpoint, internalEndpoint], + }); + expect(result).toEqual(externalEndpoint); + }); + it('should return undefined', async () => { + const result = service.getDatabaseExternalEndpoint({ + ...mockREClusterDatabase, + endpoints: [internalEndpoint], + }); + expect(result).toBeUndefined(); + }); + }); + + describe('getDatabasePersistencePolicy', () => { + it('should return AofEveryOneSecond', async () => { + const result = service.getDatabasePersistencePolicy({ + ...mockREClusterDatabase, + data_persistence: RedisEnterpriseDatabasePersistence.Aof, + aof_policy: RedisEnterpriseDatabaseAofPolicy.AofEveryOneSecond, + }); + expect(result).toEqual(RedisPersistencePolicy.AofEveryOneSecond); + }); + it('should return AofEveryWrite', async () => { + const result = service.getDatabasePersistencePolicy({ + ...mockREClusterDatabase, + data_persistence: RedisEnterpriseDatabasePersistence.Aof, + aof_policy: RedisEnterpriseDatabaseAofPolicy.AofEveryWrite, + }); + expect(result).toEqual(RedisPersistencePolicy.AofEveryWrite); + }); + it('should return SnapshotEveryOneHour', async () => { + const result = service.getDatabasePersistencePolicy({ + ...mockREClusterDatabase, + data_persistence: RedisEnterpriseDatabasePersistence.Snapshot, + snapshot_policy: [{ secs: 3600 }], + }); + expect(result).toEqual(RedisPersistencePolicy.SnapshotEveryOneHour); + }); + it('should return SnapshotEverySixHours', async () => { + const result = service.getDatabasePersistencePolicy({ + ...mockREClusterDatabase, + data_persistence: RedisEnterpriseDatabasePersistence.Snapshot, + snapshot_policy: [{ secs: 21600 }], + }); + expect(result).toEqual(RedisPersistencePolicy.SnapshotEverySixHours); + }); + it('should return SnapshotEveryTwelveHours', async () => { + const result = service.getDatabasePersistencePolicy({ + ...mockREClusterDatabase, + data_persistence: RedisEnterpriseDatabasePersistence.Snapshot, + snapshot_policy: [{ secs: 43200 }], + }); + expect(result).toEqual(RedisPersistencePolicy.SnapshotEveryTwelveHours); + }); + it('should return None', async () => { + const result = service.getDatabasePersistencePolicy({ + ...mockREClusterDatabase, + data_persistence: null, + }); + expect(result).toEqual(RedisPersistencePolicy.None); + }); + }); + + describe('findReplicasForDatabase', () => { + it('successfully return replicas', async () => { + const soursDatabase = mockREClusterDatabase; + const sourceEndpoint = mockREClusterDatabase.endpoints[0]; + const replicaDatabase: IRedisEnterpriseDatabase = { + ...mockREClusterDatabase, + uid: 1, + replica_sources: [ + { + uid: 2, + status: RedisEnterpriseDatabaseStatus.Active, + uri: `${sourceEndpoint.dns_name}:${sourceEndpoint.port}`, + }, + ], + }; + const result = service.findReplicasForDatabase( + [soursDatabase, replicaDatabase], + soursDatabase, + ); + + expect(result).toEqual([replicaDatabase]); + }); + it('source dont have replicas', async () => { + const databases = [ + mockREClusterDatabase, + { + ...mockREClusterDatabase, + uid: 3, + }, + { + ...mockREClusterDatabase, + uid: 4, + replica_sources: [ + { + uid: 3, + status: RedisEnterpriseDatabaseStatus.Active, + uri: 'redis-11400.testcluster.local:11400', + }, + ], + }, + ]; + const result = service.findReplicasForDatabase( + databases, + mockREClusterDatabase, + ); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.ts b/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.ts new file mode 100644 index 0000000000..3ce6d9476a --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.ts @@ -0,0 +1,176 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, +} from '@nestjs/common'; +import axios from 'axios'; +import * as https from 'https'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { + IRedisEnterpriseDatabase, + IRedisEnterpriseEndpoint, + IRedisEnterpriseModule, + IRedisEnterpriseReplicaSource, + RedisEnterpriseDatabaseAofPolicy, + RedisEnterpriseDatabasePersistence, +} from 'src/modules/redis-enterprise/models/redis-enterprise-database'; +import { RedisPersistencePolicy } from 'src/modules/redis-enterprise/models/redis-cloud-database'; +import { + ClusterConnectionDetailsDto, + RedisEnterpriseDatabase, +} from 'src/modules/redis-enterprise/dto/cluster.dto'; +import { convertREClusterModuleName } from 'src/modules/redis-enterprise/utils/redis-enterprise-converter'; +import { AutodiscoveryAnalyticsService } from '../autodiscovery-analytics.service/autodiscovery-analytics.service'; + +@Injectable() +export class RedisEnterpriseBusinessService { + private logger = new Logger('RedisEnterpriseBusinessService'); + + constructor(private autodiscoveryAnalyticsService: AutodiscoveryAnalyticsService) {} + + private api = axios.create({ + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + }); + + async getDatabases( + dto: ClusterConnectionDetailsDto, + ): Promise { + this.logger.log('Getting RE cluster databases.'); + const { + host, port, username, password, + } = dto; + const auth = { username, password }; + try { + const { data } = await this.api.get(`https://${host}:${port}/v1/bdbs`, { + auth, + }); + this.logger.log('Succeed to get RE cluster databases.'); + const result = this.parseClusterDbsResponse(data); + this.autodiscoveryAnalyticsService.sendGetREClusterDbsSucceedEvent(result); + return result; + } catch (error) { + const { response } = error; + let exception; + this.logger.error(`Failed to get RE cluster databases. ${error.message}`); + if (response?.status === 401 || response?.status === 403) { + exception = new ForbiddenException( + ERROR_MESSAGES.INCORRECT_CREDENTIALS(`${host}:${port}`), + ); + } else { + exception = new BadRequestException( + ERROR_MESSAGES.INCORRECT_DATABASE_URL(`${host}:${port}`), + ); + } + this.autodiscoveryAnalyticsService.sendGetREClusterDbsFailedEvent(exception); + throw exception; + } + } + + private parseClusterDbsResponse( + databases: IRedisEnterpriseDatabase[], + ): RedisEnterpriseDatabase[] { + const result: RedisEnterpriseDatabase[] = []; + databases.forEach((database) => { + const { + // eslint-disable-next-line @typescript-eslint/naming-convention + uid, name, crdt, tls_mode, crdt_replica_id, + } = database; + // Get all external endpoint, ignore others + const externalEndpoint = this.getDatabaseExternalEndpoint(database); + // Skip this database is there are no external endpoints + if (!externalEndpoint) { + return; + } + // For Active-Active (CRDT) databases, append the replica ID to the name + // so the name doesn't clash when the other replicas are added. + const dbName = crdt ? `${name}-${crdt_replica_id}` : name; + const dnsName = externalEndpoint.dns_name; + const address = externalEndpoint.addr[0]; + result.push( + new RedisEnterpriseDatabase({ + uid, + name: dbName, + dnsName, + address, + port: externalEndpoint.port, + password: database.authentication_redis_pass, + status: database.status, + tls: tls_mode === 'enabled', + modules: database.module_list.map( + (module: IRedisEnterpriseModule) => convertREClusterModuleName(module.module_name), + ), + options: { + enabledDataPersistence: + database.data_persistence + !== RedisEnterpriseDatabasePersistence.Disabled, + persistencePolicy: this.getDatabasePersistencePolicy(database), + enabledRedisFlash: database.bigstore, + enabledReplication: database.replication, + enabledBackup: database.backup, + enabledActiveActive: database.crdt, + enabledClustering: database.shards_count > 1, + isReplicaDestination: !!database?.replica_sources?.length, + isReplicaSource: !!this.findReplicasForDatabase(databases, database) + .length, + }, + }), + ); + }); + return result; + } + + public getDatabaseExternalEndpoint( + database: IRedisEnterpriseDatabase, + ): IRedisEnterpriseEndpoint { + return database.endpoints.filter((endpoint: { addr_type: string }) => endpoint.addr_type === 'external')[0]; + } + + private getDatabasePersistencePolicy( + database: IRedisEnterpriseDatabase, + ): RedisPersistencePolicy { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { data_persistence, aof_policy, snapshot_policy } = database; + if (data_persistence === RedisEnterpriseDatabasePersistence.Aof) { + return aof_policy === RedisEnterpriseDatabaseAofPolicy.AofEveryOneSecond + ? RedisPersistencePolicy.AofEveryOneSecond + : RedisPersistencePolicy.AofEveryWrite; + } + if (data_persistence === RedisEnterpriseDatabasePersistence.Snapshot) { + const { secs } = snapshot_policy.pop(); + if (secs === 3600) { + return RedisPersistencePolicy.SnapshotEveryOneHour; + } + if (secs === 21600) { + return RedisPersistencePolicy.SnapshotEverySixHours; + } + if (secs === 43200) { + return RedisPersistencePolicy.SnapshotEveryTwelveHours; + } + } + return RedisPersistencePolicy.None; + } + + private findReplicasForDatabase( + databases: IRedisEnterpriseDatabase[], + sourceDatabase: IRedisEnterpriseDatabase, + ): IRedisEnterpriseDatabase[] { + const sourceEndpoint = this.getDatabaseExternalEndpoint(sourceDatabase); + if (!sourceEndpoint) { + return []; + } + return databases.filter((replica: IRedisEnterpriseDatabase): boolean => { + const replicaSources = replica.replica_sources; + if (replica.uid === sourceDatabase.uid || !replicaSources?.length) { + return false; + } + return replicaSources.some( + (source: IRedisEnterpriseReplicaSource): boolean => source.uri.includes( + `${sourceEndpoint.dns_name}:${sourceEndpoint.port}`, + ), + ); + }); + } +} diff --git a/redisinsight/api/src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service.spec.ts b/redisinsight/api/src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service.spec.ts new file mode 100644 index 0000000000..405ce6e711 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service.spec.ts @@ -0,0 +1,168 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import * as Redis from 'ioredis-mock'; +import { ReplyError } from 'src/models'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { RedisService } from 'src/modules/core/services/redis/redis.service'; +import { ConnectionOptionsDto } from 'src/modules/instances/dto/database-instance.dto'; +import { + mockAutodiscoveryAnalyticsService, + mockRedisNoPermError, + mockRedisSentinelMasterResponse, + mockSentinelMasterDto, + mockSentinelMasterInDownState, + mockSentinelMasterInOkState, +} from 'src/__mocks__'; +import { SentinelMasterStatus } from 'src/modules/redis-sentinel/models/sentinel'; +import { RedisSentinelBusinessService } from './redis-sentinel-business.service'; +import { AutodiscoveryAnalyticsService } from '../autodiscovery-analytics.service/autodiscovery-analytics.service'; + +const mockConnectionOptions: ConnectionOptionsDto = { + host: '127.0.0.1', + port: 26379, +}; + +const mockClient = new Redis(); +mockClient.options = { + ...mockConnectionOptions, +}; + +describe('RedisSentinelBusinessService', () => { + let service: RedisSentinelBusinessService; + let redisService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisSentinelBusinessService, + { + provide: AutodiscoveryAnalyticsService, + useFactory: mockAutodiscoveryAnalyticsService, + }, + { + provide: RedisService, + useFactory: () => ({ + createStandaloneClient: jest.fn(), + }), + }, + ], + }).compile(); + + service = module.get( + RedisSentinelBusinessService, + ); + redisService = await module.get(RedisService); + mockClient.send_command = jest.fn(); + mockClient.quit = jest.fn(); + }); + + describe('connectAndGetMasters', () => { + it('connect and get sentinel masters', async () => { + mockClient.send_command.mockResolvedValue( + mockRedisSentinelMasterResponse, + ); + service.getMasterEndpoints = jest + .fn() + .mockResolvedValue([mockConnectionOptions]); + redisService.createStandaloneClient.mockResolvedValue(mockClient); + + const result = await service.connectAndGetMasters(mockConnectionOptions); + + expect(result).toEqual([mockSentinelMasterDto]); + expect(mockClient.quit).toHaveBeenCalled(); + }); + it('failed connection to the redis database', async () => { + redisService.createStandaloneClient.mockRejectedValue( + new Error(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), + ); + + await expect( + service.connectAndGetMasters(mockConnectionOptions), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getMasters', () => { + it('succeed to get sentinel masters', async () => { + service.getMasterEndpoints = jest + .fn() + .mockResolvedValue([mockConnectionOptions]); + mockClient.send_command.mockResolvedValue( + [mockSentinelMasterInOkState, mockSentinelMasterInDownState], + ); + + const result = await service.getMasters(mockClient); + + expect(mockClient.send_command).toHaveBeenCalledWith('sentinel', [ + 'masters', + ]); + expect(result).toEqual([ + mockSentinelMasterDto, + { + ...mockSentinelMasterDto, + status: SentinelMasterStatus.Down, + }, + ]); + }); + it('wrong database type', async () => { + mockClient.send_command.mockRejectedValue({ + message: + 'ERR unknown command `sentinel`, with args beginning with: `masters`', + }); + + try { + await service.getMasters(mockClient); + fail('Should throw an error'); + } catch (err) { + expect(err).toBeInstanceOf(BadRequestException); + expect(err.message).toEqual(ERROR_MESSAGES.WRONG_DISCOVERY_TOOL()); + } + }); + it("user don't have required permissions", async () => { + const error: ReplyError = { + ...mockRedisNoPermError, + command: 'SENTINEL', + }; + mockClient.send_command.mockRejectedValue(error); + + await expect(service.getMasters(mockClient)).rejects.toThrow( + ForbiddenException, + ); + }); + }); + describe('getMasterEndpoints', () => { + it('succeed to get sentinel master endpoints', async () => { + const masterName = mockSentinelMasterDto.name; + mockClient.send_command.mockResolvedValue([]); + + const result = await service.getMasterEndpoints(mockClient, masterName); + + expect(mockClient.send_command).toHaveBeenCalledWith('sentinel', [ + 'sentinels', + masterName, + ]); + expect(result).toEqual([mockConnectionOptions]); + }); + it('wrong database type', async () => { + mockClient.send_command.mockRejectedValue({ + message: + 'ERR unknown command `sentinel`, with args beginning with: `masters`', + }); + + await expect( + service.getMasterEndpoints(mockClient, mockSentinelMasterDto.name), + ).rejects.toThrow(BadRequestException); + }); + it("user don't have required permissions", async () => { + const error: ReplyError = { + ...mockRedisNoPermError, + command: 'SENTINEL', + }; + mockClient.send_command.mockRejectedValue(error); + + await expect( + service.getMasterEndpoints(mockClient, mockSentinelMasterDto.name), + ).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/redisinsight/api/src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service.ts b/redisinsight/api/src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service.ts new file mode 100644 index 0000000000..9223c73516 --- /dev/null +++ b/redisinsight/api/src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service.ts @@ -0,0 +1,118 @@ +import { + BadRequestException, + HttpException, + Injectable, + Logger, +} from '@nestjs/common'; +import IORedis from 'ioredis'; +import { + catchAclError, + convertStringsArrayToObject, + getRedisConnectionException, +} from 'src/utils'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { SentinelMaster, SentinelMasterStatus } from 'src/modules/redis-sentinel/models/sentinel'; +import { RedisService } from 'src/modules/core/services/redis/redis.service'; +import { EndpointDto } from 'src/modules/instances/dto/database-instance.dto'; +import { GetSentinelMastersDto } from 'src/modules/redis-sentinel/dto/sentinel.dto'; +import { AppTool } from 'src/models'; +import { AutodiscoveryAnalyticsService } from '../autodiscovery-analytics.service/autodiscovery-analytics.service'; + +@Injectable() +export class RedisSentinelBusinessService { + private logger = new Logger('RedisSentinelBusinessService'); + + constructor( + private redisService: RedisService, + private autodiscoveryAnalyticsService: AutodiscoveryAnalyticsService, + ) {} + + public async connectAndGetMasters( + dto: GetSentinelMastersDto, + ): Promise { + this.logger.log('Connection and getting sentinel masters.'); + let result: SentinelMaster[]; + try { + const client = await this.redisService.createStandaloneClient(dto, AppTool.Common, false); + result = await this.getMasters(client); + this.autodiscoveryAnalyticsService.sendGetSentinelMastersSucceedEvent(result); + await client.quit(); + } catch (error) { + const exception: HttpException = getRedisConnectionException(error, dto); + this.autodiscoveryAnalyticsService.sendGetSentinelMastersFailedEvent(exception); + throw exception; + } + return result; + } + + public async getMasters(client: IORedis.Redis): Promise { + this.logger.log('Getting sentinel masters.'); + let result: SentinelMaster[]; + try { + const reply = await client.send_command('sentinel', ['masters']); + result = reply.map((item) => { + const { + ip, + port, + name, + 'num-slaves': numberOfSlaves, + flags, + } = convertStringsArrayToObject(item); + return { + host: ip, + port: parseInt(port, 10), + name, + status: flags?.includes('down') ? SentinelMasterStatus.Down : SentinelMasterStatus.Active, + numberOfSlaves: parseInt(numberOfSlaves, 10), + }; + }); + await Promise.all( + result.map(async (master: SentinelMaster, index: number) => { + const endpoints = await this.getMasterEndpoints(client, master.name); + result[index] = { + ...master, + endpoints, + }; + }), + ); + } catch (error) { + this.logger.error('Failed to get sentinel masters.', error); + if (error.message.includes('unknown command `sentinel`')) { + throw new BadRequestException(ERROR_MESSAGES.WRONG_DISCOVERY_TOOL()); + } + catchAclError(error); + } + this.logger.log('Succeed to get sentinel masters.'); + return result; + } + + public async getMasterEndpoints( + client: IORedis.Redis, + masterName: string, + ): Promise { + this.logger.log('Getting a list of sentinel instances for master.'); + let result: EndpointDto[]; + try { + const reply = await client.send_command('sentinel', [ + 'sentinels', + masterName, + ]); + result = reply.map((item) => { + const { ip, port } = convertStringsArrayToObject(item); + return { host: ip, port: parseInt(port, 10) }; + }); + result = [ + { host: client.options.host, port: client.options.port }, + ...result, + ]; + } catch (error) { + this.logger.error('Failed to get a list of sentinel instances for master.', error); + if (error.message.includes('unknown command `sentinel`')) { + throw new BadRequestException(ERROR_MESSAGES.WRONG_DATABASE_TYPE); + } + catchAclError(error); + } + this.logger.log('Succeed to get a list of sentinel instances for master.'); + return result; + } +} diff --git a/redisinsight/api/src/modules/shared/shared.module.ts b/redisinsight/api/src/modules/shared/shared.module.ts new file mode 100644 index 0000000000..0afc937e59 --- /dev/null +++ b/redisinsight/api/src/modules/shared/shared.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import config from 'src/utils/config'; +import { CoreModule } from 'src/modules/core/core.module'; +import { DatabaseInstanceEntity } from 'src/modules/core/models/database-instance.entity'; +import { + RedisSentinelBusinessService, +} from 'src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service'; +import { DatabasesProvider } from 'src/modules/shared/services/instances-business/databases.provider'; +import { OverviewService } from 'src/modules/shared/services/instances-business/overview.service'; +import { InstancesBusinessService } from './services/instances-business/instances-business.service'; +import { RedisEnterpriseBusinessService } from './services/redis-enterprise-business/redis-enterprise-business.service'; +import { RedisCloudBusinessService } from './services/redis-cloud-business/redis-cloud-business.service'; +import { ConfigurationBusinessService } from './services/configuration-business/configuration-business.service'; +import { InstancesAnalyticsService } from './services/instances-business/instances-analytics.service'; +import { + AutodiscoveryAnalyticsService, +} from './services/autodiscovery-analytics.service/autodiscovery-analytics.service'; + +const SERVER_CONFIG = config.get('server'); + +@Module({ + imports: [ + CoreModule.register({ + buildType: SERVER_CONFIG.buildType, + }), + TypeOrmModule.forFeature([DatabaseInstanceEntity]), + ], + providers: [ + DatabasesProvider, + InstancesBusinessService, + InstancesAnalyticsService, + RedisEnterpriseBusinessService, + RedisCloudBusinessService, + ConfigurationBusinessService, + OverviewService, + RedisSentinelBusinessService, + AutodiscoveryAnalyticsService, + ], + exports: [ + InstancesBusinessService, + RedisEnterpriseBusinessService, + RedisCloudBusinessService, + ConfigurationBusinessService, + RedisSentinelBusinessService, + ], +}) +export class SharedModule {} diff --git a/redisinsight/api/src/modules/shared/utils/database-entity-converter.ts b/redisinsight/api/src/modules/shared/utils/database-entity-converter.ts new file mode 100644 index 0000000000..c3ea37a227 --- /dev/null +++ b/redisinsight/api/src/modules/shared/utils/database-entity-converter.ts @@ -0,0 +1,45 @@ +import { DatabaseInstanceResponse } from 'src/modules/instances/dto/database-instance.dto'; +import { DatabaseInstanceEntity } from 'src/modules/core/models/database-instance.entity'; + +export const convertEntityToDto = (database: DatabaseInstanceEntity): DatabaseInstanceResponse => { + if (database) { + const { + tls, + verifyServerCert, + caCert, + clientCert, + nodes, + sentinelMasterName, + sentinelMasterPassword, + sentinelMasterUsername, + modules, + encryption, + ...rest + } = database; + const result: DatabaseInstanceResponse = { + modules: modules ? JSON.parse(modules) : [], + ...rest, + }; + if (nodes) { + result.endpoints = JSON.parse(nodes); + } + if (sentinelMasterName) { + result.sentinelMaster = { + name: sentinelMasterName, + password: sentinelMasterPassword, + username: sentinelMasterUsername, + }; + } + if (tls) { + result.tls = { verifyServerCert: verifyServerCert || false }; + if (caCert) { + result.tls.caCertId = caCert.id; + } + if (clientCert) { + result.tls.clientCertPairId = clientCert.id; + } + } + return result; + } + return null; +}; diff --git a/redisinsight/api/src/utils/analytics-helper.spec.ts b/redisinsight/api/src/utils/analytics-helper.spec.ts new file mode 100644 index 0000000000..850d71eec2 --- /dev/null +++ b/redisinsight/api/src/utils/analytics-helper.spec.ts @@ -0,0 +1,97 @@ +import { + calculateRedisHitRatio, + getJsonPathLevel, + getRangeForNumber, +} from 'src/utils/analytics-helper'; + +/* eslint-disable sonarjs/no-duplicate-string */ +const getRangeForNumberTests = [ + { input: null, output: undefined }, + { input: undefined, output: undefined }, + { input: 0, output: '0 - 500 000' }, + { input: 100, output: '0 - 500 000' }, + { input: 500000, output: '0 - 500 000' }, + { input: 500001, output: '500 001 - 1 000 000' }, + { input: 600000, output: '500 001 - 1 000 000' }, + { input: 1000000, output: '500 001 - 1 000 000' }, + { input: 1000001, output: '1 000 001 - 10 000 000' }, + { input: 2000000, output: '1 000 001 - 10 000 000' }, + { input: 10000000, output: '1 000 001 - 10 000 000' }, + { input: 10000001, output: '10 000 001 - 50 000 000' }, + { input: 20000000, output: '10 000 001 - 50 000 000' }, + { input: 50000000, output: '10 000 001 - 50 000 000' }, + { input: 50000001, output: '50 000 001 - 100 000 000' }, + { input: 60000000, output: '50 000 001 - 100 000 000' }, + { input: 100000000, output: '50 000 001 - 100 000 000' }, + { input: 100000001, output: '100 000 001 - 1 000 000 000' }, + { input: 200000000, output: '100 000 001 - 1 000 000 000' }, + { input: 1000000000, output: '100 000 001 - 1 000 000 000' }, + { input: 1000000001, output: '1 000 000 001 +' }, + { input: 2000000000, output: '1 000 000 001 +' }, +]; +/* eslint-enable sonarjs/no-duplicate-string */ + +const getJsonPathLevelTests = [ + { input: '.', output: 'root' }, + { input: '', output: 'root' }, + { input: '.foo', output: '0' }, + { input: 'foo', output: '0' }, + { input: '.foo["bar"]', output: '1' }, + { input: 'foo["bar"]', output: '1' }, + { input: 'foo[0]["bar"]', output: '2' }, + { input: '[\'foo\']["bar"]', output: '1' }, + { input: '[\'foo\'][0].bar["test"]', output: '3' }, +]; + +const calculateRedisHitRatioTests = [ + { input: { hits: null, misses: null }, output: undefined }, + { input: { hits: undefined, misses: undefined }, output: undefined }, + { input: { hits: 1, misses: undefined }, output: undefined }, + { input: { hits: undefined, misses: 1 }, output: undefined }, + { input: { hits: null, misses: 1 }, output: undefined }, + { input: { hits: 1, misses: null }, output: undefined }, + { input: { hits: NaN, misses: NaN }, output: undefined }, + { input: { hits: NaN, misses: NaN }, output: undefined }, + { input: { hits: NaN, misses: 'string' }, output: undefined }, + { input: { hits: 'string', misses: 'string' }, output: undefined }, + { input: { hits: 2, misses: 2 }, output: 0.5 }, + { input: { hits: 1, misses: 2 }, output: 0.3333333333333333 }, + { input: { hits: 62409, misses: 0 }, output: 1 }, + { input: { hits: 62409, misses: 109669 }, output: 0.3626785527493346 }, + { input: { hits: '62409', misses: '109669' }, output: 0.3626785527493346 }, + { input: { hits: '62409', misses: 109669 }, output: 0.3626785527493346 }, + { input: { hits: '0', misses: 109669 }, output: 1 }, + { input: { hits: 0, misses: 109669 }, output: 1 }, +]; + +describe('getRangeForNumber', () => { + getRangeForNumberTests.forEach((test) => { + it(`should be output: ${test.output} for input: ${test.input} `, async () => { + const result = getRangeForNumber(test.input); + + expect(result).toEqual(test.output); + }); + }); +}); + +describe('getJsonPathLevel', () => { + getJsonPathLevelTests.forEach((test) => { + it(`should be output: ${test.output} for input: ${test.input} `, async () => { + const result = getJsonPathLevel(test.input); + + expect(result).toEqual(test.output); + }); + }); +}); + +describe('calculateRedisHitRatio', () => { + calculateRedisHitRatioTests.forEach((test) => { + it(`should be output: ${test.output} for input: ${JSON.stringify( + test.input, + )} `, async () => { + const result = calculateRedisHitRatio(test.input.hits, test.input.misses); + + expect(result).toEqual(test.output); + }); + }); +}); diff --git a/redisinsight/api/src/utils/analytics-helper.ts b/redisinsight/api/src/utils/analytics-helper.ts new file mode 100644 index 0000000000..96e43c4c30 --- /dev/null +++ b/redisinsight/api/src/utils/analytics-helper.ts @@ -0,0 +1,80 @@ +import * as jsonpath from 'jsonpath'; +import { isNil } from 'lodash'; + +export const TOTAL_KEYS_BREAKPOINTS = [ + 500000, + 1000000, + 10000000, + 50000000, + 100000000, + 1000000000, +]; + +export const SCAN_THRESHOLD_BREAKPOINTS = [ + 5000, + 10000, + 50000, + 100000, + 1000000, +]; + +const numberWithSpaces = (x: number): string => x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' '); + +export const getRangeForNumber = ( + value: number, + breakpoints: number[] = TOTAL_KEYS_BREAKPOINTS, +): string => { + if (isNil(value)) { + return undefined; + } + const index = breakpoints.findIndex( + (threshold: number) => value <= threshold, + ); + if (index === 0) { + return `0 - ${numberWithSpaces(breakpoints[0])}`; + } + if (index === -1) { + const lastItem = breakpoints[breakpoints.length - 1]; + return `${numberWithSpaces(lastItem + 1)} +`; + } + return `${numberWithSpaces( + breakpoints[index - 1] + 1, + )} - ${numberWithSpaces(breakpoints[index])}`; +}; + +export const getJsonPathLevel = (path: string): string => { + try { + if (path === '.') { + return 'root'; + } + const levelsLength = jsonpath.parse( + `$${path.startsWith('.') ? '' : '..'}${path}`, + ).length; + if (levelsLength === 1) { + return 'root'; + } + return `${levelsLength - 2}`; + } catch (e) { + return 'root'; + } +}; + +export const calculateRedisHitRatio = ( + keyspaceHits: string | number, + keyspaceMisses: string | number, +): number => { + try { + if (isNil(keyspaceHits) || isNil(keyspaceMisses)) { + return undefined; + } + const keyspaceHitsValue = +keyspaceHits; + const keyspaceMissesValue = +keyspaceMisses; + if (keyspaceHitsValue === 0) { + return 1; + } + const result = keyspaceHitsValue / (keyspaceHitsValue + keyspaceMissesValue); + return Number.isNaN(result) ? undefined : result; + } catch (error) { + return undefined; + } +}; diff --git a/redisinsight/api/src/utils/catch-redis-errors.ts b/redisinsight/api/src/utils/catch-redis-errors.ts new file mode 100644 index 0000000000..7f599519c9 --- /dev/null +++ b/redisinsight/api/src/utils/catch-redis-errors.ts @@ -0,0 +1,143 @@ +import { + BadRequestException, + ForbiddenException, + GatewayTimeoutException, + HttpException, + HttpStatus, + InternalServerErrorException, + MethodNotAllowedException, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ReplyError } from 'src/models'; +import { RedisErrorCodes, CertificatesErrorCodes } from 'src/constants'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { ConnectionOptionsDto } from 'src/modules/instances/dto/database-instance.dto'; +import { EncryptionServiceErrorException } from 'src/modules/core/encryption/exceptions'; + +export const isCertError = (error: ReplyError): boolean => { + try { + const errorCodesArray: string[] = Object.values(CertificatesErrorCodes); + return errorCodesArray.includes(error.code) + || error.code?.includes(CertificatesErrorCodes.OSSLError) + || error.message.includes('SSL') + || error.message.includes(CertificatesErrorCodes.OSSLError) + || error.message.includes(CertificatesErrorCodes.IncorrectCertificates) + || error.message.includes('ERR unencrypted connection is prohibited'); + } catch (e) { + return false; + } +}; + +export const getRedisConnectionException = ( + error: ReplyError, + connectionOptions: ConnectionOptionsDto, + errorPlaceholder: string = '', +): HttpException => { + const { host, port } = connectionOptions; + if (error?.message) { + if (error.message.includes(RedisErrorCodes.SentinelParamsRequired)) { + return new HttpException( + { + statusCode: HttpStatus.BAD_REQUEST, + error: RedisErrorCodes.SentinelParamsRequired, + message: ERROR_MESSAGES.SENTINEL_MASTER_NAME_REQUIRED, + }, + HttpStatus.BAD_REQUEST, + ); + } + + if ( + error.message.includes(RedisErrorCodes.Timeout) + || error.message.includes('timed out') + ) { + return new GatewayTimeoutException(ERROR_MESSAGES.CONNECTION_TIMEOUT); + } + + if ( + error.message.includes(RedisErrorCodes.InvalidPassword) + || error.message.includes(RedisErrorCodes.AuthRequired) + || error.message === 'ERR invalid password' + ) { + return new UnauthorizedException(ERROR_MESSAGES.AUTHENTICATION_FAILED()); + } + + if (error.message === "ERR unknown command 'auth'") { + return new MethodNotAllowedException( + ERROR_MESSAGES.COMMAND_NOT_SUPPORTED('auth'), + ); + } + + if ( + error.message.includes(RedisErrorCodes.ConnectionRefused) + || error.message.includes(RedisErrorCodes.ConnectionNotFound) + || error.message.includes(RedisErrorCodes.DNSTimeoutError) + || error.message.includes('Failed to refresh slots cache') + || error?.code === RedisErrorCodes.ConnectionReset + ) { + return new ServiceUnavailableException( + ERROR_MESSAGES.INCORRECT_DATABASE_URL( + errorPlaceholder || `${host}:${port}`, + ), + ); + } + if (isCertError(error)) { + const message = ERROR_MESSAGES.INCORRECT_CERTIFICATES(errorPlaceholder || `${host}:${port}`); + return new BadRequestException(message); + } + } + + // todo: Move to other place after refactoring + if (error instanceof EncryptionServiceErrorException) { + return error; + } + + if (error?.message) { + return new BadRequestException(error.message); + } + return new InternalServerErrorException(); +}; + +export const catchRedisConnectionError = ( + error: ReplyError, + connectionOptions: ConnectionOptionsDto, + errorPlaceholder: string = '', +): HttpException => { + throw getRedisConnectionException(error, connectionOptions, errorPlaceholder); +}; + +export const catchAclError = (error: ReplyError): HttpException => { + // todo: Move to other place after refactoring + if (error instanceof EncryptionServiceErrorException) { + throw error; + } + + if (error?.message?.includes(RedisErrorCodes.NoPermission)) { + throw new ForbiddenException(error.message); + } + if (error?.previousErrors?.length) { + const noPermError: ReplyError = error.previousErrors.find(( + errorItem, + ) => errorItem?.message?.includes(RedisErrorCodes.NoPermission)); + + if (noPermError) { + throw new ForbiddenException(noPermError.message); + } + } + throw new InternalServerErrorException(error.message); +}; + +export const catchTransactionError = ( + transactionError: ReplyError | null, + transactionResults: [ReplyError, any][], +): void => { + if (transactionError) { + throw transactionError; + } + const previousErrors = transactionResults + .map((item: [ReplyError, any]) => item[0]) + .filter((item) => !!item); + if (previousErrors.length) { + throw previousErrors[0]; + } +}; diff --git a/redisinsight/api/src/utils/cli-helper.spec.ts b/redisinsight/api/src/utils/cli-helper.spec.ts new file mode 100644 index 0000000000..502b0f81a3 --- /dev/null +++ b/redisinsight/api/src/utils/cli-helper.spec.ts @@ -0,0 +1,280 @@ +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CliParsingError, RedirectionParsingError } from 'src/modules/cli/constants/errors'; +import { + mockRedisAskError, + mockRedisMovedError, + mockRedisNoPermError, + mockRedisWrongTypeError, +} from 'src/__mocks__'; +import { + checkHumanReadableCommands, + splitCliCommandLine, + getBlockingCommands, + checkRedirectionError, + parseRedirectionError, getRedisPipelineSummary, +} from 'src/utils/cli-helper'; + +describe('Cli helper', () => { + describe('splitCliCommandLine', () => { + it('should correctly split simple command with args', () => { + const input = 'memory usage key'; + + const output = splitCliCommandLine(input); + + expect(output).toEqual(['memory', 'usage', 'key']); + }); + it('should correctly split command with special symbols in the args in the double quotes', () => { + const input = 'set test "—"'; + + const output = splitCliCommandLine(input); + const buffer = Buffer.from('e28094', 'hex'); + expect(output).toEqual(['set', 'test', buffer]); + }); + // todo: enable after review splitCliCommandLine functionality + xit('should correctly split command with special symbols in the args in the single quotes', () => { + const input = "set test '—'"; + + const output = splitCliCommandLine(input); + + const buffer = Buffer.from('e28094', 'hex'); + expect(output).toEqual(['set', 'test', buffer]); + }); + it('should correctly split simple command without args', () => { + const input = 'info'; + + const output = splitCliCommandLine(input); + + expect(output).toEqual(['info']); + }); + it('should correctly split command with double quotes', () => { + const input = 'get "key name"'; + + const output = splitCliCommandLine(input); + expect(output).toEqual(['get', Buffer.from('key name')]); + }); + it('should correctly split command with single quotes', () => { + const input = "get 'key name'"; + + const output = splitCliCommandLine(input); + + expect(output).toEqual(['get', 'key name']); + }); + it('should correctly handle special character', () => { + const input = 'set key "\\a\\b\\t\\n\\r"'; + const output = splitCliCommandLine(input); + + expect(output).toEqual([ + 'set', + 'key', + Buffer.alloc(5, String.fromCharCode(7, 8, 9, 10, 13)), + ]); + }); + it('should correctly handle hexadecimal', () => { + const input = 'set key "\\xac\\xed"'; + const output = splitCliCommandLine(input); + + expect(output).toEqual(['set', 'key', Buffer.from([172, 237])]); + }); + it('should throw [CLI_INVALID_QUOTES_CLOSING] error for command with double quotes', () => { + const input = 'get "key"a'; + + try { + splitCliCommandLine(input); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(CliParsingError); + expect(err.message).toEqual( + ERROR_MESSAGES.CLI_INVALID_QUOTES_CLOSING(), + ); + } + }); + it('should throw [CLI_UNTERMINATED_QUOTES] error for command with double quotes', () => { + const input = 'get "\\\\key'; + + try { + splitCliCommandLine(input); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(CliParsingError); + expect(err.message).toEqual(ERROR_MESSAGES.CLI_UNTERMINATED_QUOTES()); + } + }); + it('should throw [CLI_INVALID_QUOTES_CLOSING] error for command with single quotes', () => { + const input = "get 'key'a"; + + try { + splitCliCommandLine(input); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(CliParsingError); + expect(err.message).toEqual( + ERROR_MESSAGES.CLI_INVALID_QUOTES_CLOSING(), + ); + } + }); + it('should throw [CLI_UNTERMINATED_QUOTES] error for command with single quotes', () => { + const input = "get 'key"; + + try { + splitCliCommandLine(input); + fail(); + } catch (err) { + expect(err).toBeInstanceOf(CliParsingError); + expect(err.message).toEqual(ERROR_MESSAGES.CLI_UNTERMINATED_QUOTES()); + } + }); + }); + + describe('checkHumanReadableCommands', () => { + const tests = [ + { input: 'info', output: true }, + { input: 'info server', output: true }, + { input: 'lolwut', output: true }, + { input: 'LOLWUT', output: true }, + { input: 'debug hstats', output: true }, + { input: 'debug hstats-key', output: true }, + { input: 'DEBUG HSTATS-KEY', output: true }, + { input: 'memory doctor', output: true }, + { input: 'memory malloc-stats', output: true }, + { input: 'cluster nodes', output: true }, + { input: 'cluster info', output: true }, + { input: 'client list', output: true }, + { input: 'latency graph', output: true }, + { input: 'latency doctor', output: true }, + { input: 'proxy info', output: true }, + { input: 'PROXY INFO', output: true }, + { input: 'get key', output: false }, + { input: 'debug object', output: false }, + { input: 'DEBUG OBJECT', output: false }, + { input: 'client kill', output: false }, + { input: 'scan 0 COUNT 15 MATCH *', output: false }, + ]; + tests.forEach((test) => { + it(`should be output: ${test.output} for input: ${test.input} `, async () => { + const result = checkHumanReadableCommands(test.input); + + expect(result).toEqual(test.output); + }); + }); + }); + + describe('getBlockingCommands', () => { + it('should return fixed predefined list of blocking commands', () => { + expect(getBlockingCommands()).toEqual([ + 'blpop', + 'brpop', + 'blmove', + 'brpoplpush', + 'bzpopmin', + 'bzpopmax', + 'xread', + 'xreadgroup', + ]); + }); + }); + + describe('checkRedirectionError', () => { + const tests: Record[] = [ + { input: mockRedisAskError, output: true }, + { input: mockRedisMovedError, output: true }, + { input: mockRedisNoPermError, output: false }, + { input: mockRedisWrongTypeError, output: false }, + { input: 'info', output: false }, + { input: undefined, output: false }, + { input: false, output: false }, + { input: null, output: false }, + { input: {}, output: false }, + ]; + tests.forEach((test) => { + it(`should be output: ${test.output} for input: ${test.input} `, async () => { + expect(checkRedirectionError(test.input)).toEqual(test.output); + }); + }); + }); + + describe('parseRedirectionError', () => { + it('should get slot and address from MOVED error', () => { + const result = parseRedirectionError(mockRedisMovedError); + + expect(result).toEqual({ + slot: '7008', + address: '127.0.0.1:7002', + }); + }); + it('should get slot and address from ASK error', () => { + const result = parseRedirectionError({ + ...mockRedisAskError, + message: 'ASK 7008 redis.cloud.redislabs.com:17182', + }); + + expect(result).toEqual({ + slot: '7008', + address: 'redis.cloud.redislabs.com:17182', + }); + }); + it('should throw exception for wrong node address', () => { + const redirectionError = { + ...mockRedisAskError, + message: 'ASK 7008 redis.cloud.redislabs.com/test', + }; + expect(() => parseRedirectionError(redirectionError)).toThrow(RedirectionParsingError); + }); + it('should throw exception for incorrect redirection message format', () => { + const redirectionError = { + ...mockRedisAskError, + message: 'ASK redis.cloud.redislabs.com:17182 7008', + }; + expect(() => parseRedirectionError(redirectionError)).toThrow(RedirectionParsingError); + }); + it('should throw exception', () => { + const input: any = 'ASK redis.cloud.redislabs.com:17182 7008'; + expect(() => parseRedirectionError(input)).toThrow(RedirectionParsingError); + }); + }); + + describe('getRedisPipelineSummary', () => { + const pipeline = Array(50).fill(['get', 'foo']); + const tests: Record[] = [ + { + input: { pipeline, limit: undefined }, + output: { + length: pipeline.length, + summary: JSON.stringify([...Array(5).fill('get'), '...']), + }, + }, + { + input: { pipeline, limit: 10 }, + output: { + length: pipeline.length, + summary: JSON.stringify([...Array(10).fill('get'), '...']), + }, + }, + { + input: { pipeline, limit: 1000 }, + output: { + length: pipeline.length, + summary: JSON.stringify([...Array(50).fill('get')]), + }, + }, + { + input: { pipeline: {}, limit: 1000 }, + output: { + length: 0, + summary: '[]', + }, + }, + { + input: { pipeline, limit: -10 }, + output: { + length: pipeline.length, + summary: JSON.stringify(['...']), + }, + }, + ]; + tests.forEach((test) => { + it(`should be output: ${JSON.stringify(test.output)} for input: ${JSON.stringify(test.input)} `, async () => { + expect(getRedisPipelineSummary(test.input.pipeline, test.input.limit)).toEqual(test.output); + }); + }); + }); +}); diff --git a/redisinsight/api/src/utils/cli-helper.ts b/redisinsight/api/src/utils/cli-helper.ts new file mode 100644 index 0000000000..dc81f43304 --- /dev/null +++ b/redisinsight/api/src/utils/cli-helper.ts @@ -0,0 +1,229 @@ +import { take } from 'lodash'; +import config from 'src/utils/config'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CliParsingError, RedirectionParsingError } from 'src/modules/cli/constants/errors'; +import { ReplyError } from 'src/models'; +import { IRedirectionInfo } from 'src/modules/cli/services/cli-business/output-formatter/output-formatter.interface'; + +const REDIS_CLI_CONFIG = config.get('redis_cli'); +const LOGGER_CONFIG = config.get('logger'); + +export enum CliToolUnsupportedCommands { + Monitor = 'monitor', + Subscribe = 'subscribe', + PSubscribe = 'psubscribe', + Sync = 'sync', + PSync = 'psync', + ScriptDebug = 'script debug', +} + +export enum CliToolBlockingCommands { + BLPop = 'blpop', + BRPop = 'brpop', + BLMove = 'blmove', + BRPopLPush = 'brpoplpush', + BZPopMin = 'bzpopmin', + BZPopMax = 'bzpopmax', + XRead = 'xread', + XReadGroup = 'xreadgroup', +} + +export enum CliToolHumanReadableCommands { + Info = 'info', + Lolwut = 'lolwut', + DebugHStats = 'debug hstats', + DebugHStatsKey = 'debug hstats-key', + MemoryDoctor = 'memory doctor', + MemoryMallocStats = 'memory malloc-stats', + ClusterNodes = 'cluster nodes', + ClusterInfo = 'cluster info', + ClientList = 'client list', + LatencyGraph = 'latency graph', + LatencyDoctor = 'latency doctor', + ProxyInfo = 'proxy info', +} + +function isHex(str: string) { + return /^[A-F0-9]{1,2}$/i.test(str); +} + +function getSpecChar(str: string): string { + let char; + switch (str) { + case 'a': + char = String.fromCharCode(7); + break; + case 'b': + char = String.fromCharCode(8); + break; + case 't': + char = String.fromCharCode(9); + break; + case 'n': + char = String.fromCharCode(10); + break; + case 'r': + char = String.fromCharCode(13); + break; + default: + char = str; + } + return char; +} + +// todo: review/rewrite this function. Pay attention on handling data inside '' vs "" +export const splitCliCommandLine = (line: string): string[] => { + // Splits a command line into a list of arguments. + // Ported from sdssplitargs() function in sds.c from Redis source code. + // This is the function redis-cli uses to parse command lines. + let i = 0; + let currentArg = null; + const args = []; + while (i < line.length) { + /* skip blanks */ + while (line[i] === ' ') i += 1; + let inq = false; /* set to True if we are in "quotes" */ + let insq = false; /* set to True if we are in 'single quotes' */ + let done = false; + while (!done) { + if (inq) { + // Handle double quotes + if (i >= line.length) { + // unterminated quotes + throw new CliParsingError(ERROR_MESSAGES.CLI_UNTERMINATED_QUOTES()); + } else if ( + line[i] === '\\' + && line[i + 1] === 'x' + && isHex(`${line[i + 2]}${line[i + 3]}`) + ) { + const charCode = parseInt(`0x${line[i + 2]}${line[i + 3]}`, 16); + currentArg = Buffer.concat([ + currentArg, + Buffer.alloc(1, charCode, 'binary'), + ]); + i += 3; + } else if (line[i] === '\\' && i < line.length) { + // Handle special characters + i += 1; + const c = getSpecChar(line[i]); + currentArg = Buffer.concat([ + currentArg, + Buffer.alloc(1, c, 'binary'), + ]); + } else if (line[i] === '"') { + // closing quote must be followed by a space or nothing at all. + if (i + 1 < line.length && line[i + 1] !== ' ') { + throw new CliParsingError( + ERROR_MESSAGES.CLI_INVALID_QUOTES_CLOSING(), + ); + } + done = true; + } else { + currentArg = Buffer.concat([ + currentArg, + Buffer.from(line[i], 'utf8'), + ]); + } + } else if (insq) { + // Handle single quotes + if (i >= line.length) { + // unterminated quotes + throw new CliParsingError(ERROR_MESSAGES.CLI_UNTERMINATED_QUOTES()); + } else if (line[i] === '\\' && line[i + 1] === "'") { + i += 1; + currentArg += "'"; + } else if (line[i] === "'") { + // closing quote must be followed by a space or nothing at all. + if (i + 1 < line.length && line[i + 1] !== ' ') { + throw new CliParsingError( + ERROR_MESSAGES.CLI_INVALID_QUOTES_CLOSING(), + ); + } + done = true; + } else { + currentArg = `${currentArg}${line[i]}`; + } + } else if (i >= line.length) { + done = true; + } else if ([' ', '\n', '\r', '\t', '\0'].includes(line[i])) { + done = true; + } else if (line[i] === '"') { + currentArg = Buffer.alloc(0); + inq = true; + } else if (line[i] === "'") { + currentArg = ''; + insq = true; + } else { + currentArg = `${currentArg || ''}${line[i]}`; + } + if (i < line.length) i += 1; + } + args.push(currentArg); + currentArg = null; + } + return args; +}; + +export const getUnsupportedCommands = (): string[] => [ + ...Object.values(CliToolUnsupportedCommands), + ...REDIS_CLI_CONFIG.unsupportedCommands, +]; + +export const getBlockingCommands = (): string[] => Object.values(CliToolBlockingCommands); + +export function decimalToHexString(d: number, padding: number = 2): string { + const hex = Number(d).toString(16); + return '0'.repeat(padding).substr(0, padding - hex.length) + hex; +} + +export function checkHumanReadableCommands(commandLine: string): boolean { + // The list of command got from cliSendCommand() function in redis-cli.c from Redis source code. + return !!Object.values(CliToolHumanReadableCommands) + .find((command) => commandLine.toLowerCase().startsWith(command)); +} + +export function checkRedirectionError(error: ReplyError): boolean { + try { + return error.message.startsWith('MOVED') || error.message.startsWith('ASK'); + } catch (e) { + return false; + } +} + +export function parseRedirectionError(error: ReplyError): IRedirectionInfo { + try { + const [, slot, address] = error.message.split(' '); + const { port } = new URL(`redis://${address}`); + if (!port) { + throw new Error(); + } + return { slot, address }; + } catch (e) { + throw new RedirectionParsingError(); + } +} + +interface IPipelineSummary { + summary: string, + length: number, +} + +export function getRedisPipelineSummary( + pipeline: Array<[toolCommand: any, ...args: Array]>, + limit: number = LOGGER_CONFIG.pipelineSummaryLimit, +): IPipelineSummary { + const result: IPipelineSummary = { + summary: '[]', + length: 0, + }; + try { + const commands = pipeline.reduce((prev, cur) => [...prev, cur[0]], []); + result.length = commands.length; + result.summary = commands.length > limit + ? JSON.stringify([...take(commands, limit), '...']) + : JSON.stringify(commands); + } catch (e) { + // continue regardless of error + } + return result; +} diff --git a/redisinsight/api/src/utils/config.spec.ts b/redisinsight/api/src/utils/config.spec.ts new file mode 100644 index 0000000000..1fa51f1e98 --- /dev/null +++ b/redisinsight/api/src/utils/config.spec.ts @@ -0,0 +1,61 @@ +import defaultConfig from '../../config/default'; +import devConfig from '../../config/development'; +import stageConfig from '../../config/staging'; +import prodConfig from '../../config/production'; + +describe('Config util', () => { + const OLD_ENV = process.env; + + describe('get', () => { + beforeEach(() => { + // Clears the cache + jest.resetModules(); + // Make a copy + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + // Restore old environment + process.env = OLD_ENV; + }); + + it('should return dev server config', () => { + process.env.NODE_ENV = 'development'; + // eslint-disable-next-line global-require + const { get } = require('./config'); + + const result = get('server'); + + expect(result).toEqual({ + ...defaultConfig.server, + ...devConfig.server, + }); + }); + + it('should return stage server config', () => { + process.env.NODE_ENV = 'staging'; + // eslint-disable-next-line global-require + const { get } = require('./config'); + + const result = get('server'); + + expect(result).toEqual({ + ...defaultConfig.server, + ...stageConfig.server, + }); + }); + + it('should return prod server config', () => { + process.env.NODE_ENV = 'production'; + // eslint-disable-next-line global-require + const { get } = require('./config'); + + const result = get('server'); + + expect(result).toEqual({ + ...defaultConfig.server, + ...prodConfig.server, + }); + }); + }); +}); diff --git a/redisinsight/api/src/utils/config.ts b/redisinsight/api/src/utils/config.ts new file mode 100644 index 0000000000..59ecb70388 --- /dev/null +++ b/redisinsight/api/src/utils/config.ts @@ -0,0 +1,28 @@ +import { merge, cloneDeep } from 'lodash'; +import defaultConfig from '../../config/default'; +import development from '../../config/development'; +import staging from '../../config/staging'; +import production from '../../config/production'; + +const config = cloneDeep(defaultConfig); + +let envConfig; +switch (process.env.NODE_ENV) { + case 'staging': + envConfig = staging; + break; + case 'production': + envConfig = production; + break; + default: + envConfig = development; + break; +} + +merge(config, envConfig); + +export const get = (key: string) => config[key]; + +export default { + get, +}; diff --git a/redisinsight/api/src/utils/converter.spec.ts b/redisinsight/api/src/utils/converter.spec.ts new file mode 100644 index 0000000000..5999d2902d --- /dev/null +++ b/redisinsight/api/src/utils/converter.spec.ts @@ -0,0 +1,44 @@ +import { flatMap } from 'lodash'; +import { convertStringsArrayToObject, convertIntToSemanticVersion } from './converter'; + +describe('convertStringsArrayToObject', () => { + it('should return appropriate value', () => { + const input = ['key1', 'value1', 'key2', 'value2']; + + const output = convertStringsArrayToObject(input); + + expect(flatMap(Object.entries(output))).toEqual(input); + }); + it('should return empty object', () => { + const output = convertStringsArrayToObject([]); + + expect({}).toEqual(output); + }); +}); + +const convertIntToSemanticVersionTests: Record[] = [ + { input: 1, output: '0.0.1' }, + { input: 10, output: '0.0.10' }, + { input: 100, output: '0.1.0' }, + { input: 1000, output: '0.10.0' }, + { input: 10000, output: '1.0.0' }, + { input: 100000, output: '10.0.0' }, + { input: 1000000, output: '100.0.0' }, + { input: 10410, output: '1.4.10' }, + { input: 10008, output: '1.0.8' }, + { input: 20407, output: '2.4.7' }, + { input: 20011, output: '2.0.11' }, + { input: 20206, output: '2.2.6' }, + { input: 0, output: undefined }, + { input: 'string', output: undefined }, +]; + +describe('convertIntToSemanticVersionTests', () => { + convertIntToSemanticVersionTests.forEach((test) => { + it(`should be output: ${test.output} for input: ${JSON.stringify(test.input)}`, () => { + const result = convertIntToSemanticVersion(test.input); + + expect(result).toEqual(test.output); + }); + }); +}); diff --git a/redisinsight/api/src/utils/converter.ts b/redisinsight/api/src/utils/converter.ts new file mode 100644 index 0000000000..5be0361dce --- /dev/null +++ b/redisinsight/api/src/utils/converter.ts @@ -0,0 +1,26 @@ +import { chunk, isInteger } from 'lodash'; + +export const convertStringsArrayToObject = (input: string[]): { [key: string]: any } => chunk( + input, + 2, +).reduce((prev: any, current: string[]) => { + const [key, value] = current; + return { ...prev, [key.toLowerCase()]: value }; +}, {}); + +export const convertIntToSemanticVersion = (input: number): string => { + const separator = '.'; + try { + if (isInteger(input) && input > 0) { + // Pad input with optional zero symbols + const version = String(input).padStart(6, '0'); + const patch = parseInt(version.slice(-2), 10); + const minor = parseInt(version.slice(-4, -2), 10); + const major = parseInt(version.slice(0, -4), 10); + return [major, minor, patch].join(separator); + } + return undefined; + } catch (e) { + return undefined; + } +}; diff --git a/redisinsight/api/src/utils/glob-pattern-helper.spec.ts b/redisinsight/api/src/utils/glob-pattern-helper.spec.ts new file mode 100644 index 0000000000..e3c8716937 --- /dev/null +++ b/redisinsight/api/src/utils/glob-pattern-helper.spec.ts @@ -0,0 +1,32 @@ +import { unescapeGlob } from 'src/utils/glob-pattern-helper'; + +const unescapeGlobTests = [ + { input: 'h?llo', output: 'h?llo' }, + { input: 'h\\?llo', output: 'h?llo' }, + { input: '\\!hello', output: '!hello' }, + { input: '\\*hello', output: '*hello' }, + { input: 'hello\\*', output: 'hello*' }, + { input: 'h\\(a|e\\)llo', output: 'h(a|e)llo' }, + { input: 'h\\[a-e\\]llo', output: 'h[a-e]llo' }, + { input: 'h\\[^a\\]llo', output: 'h[^a]llo' }, + { input: 'h\\[a-e\\]llo\\\\:foo', output: 'h[a-e]llo\\:foo' }, + { input: 'h\\{a,e\\}llo', output: 'h{a,e}llo' }, + { input: 'h\\{a,e}llo', output: 'h{a,e}llo' }, + { input: 'h\\[a-e\\]llo\\\\\\*', output: 'h[a-e]llo\\*' }, + { input: 'h\\?(a)llo', output: 'h?(a)llo' }, + { input: 'hello/\\!\\(a\\)llo', output: 'hello/!(a)llo' }, + { input: 'hello/\\+(a)llo', output: 'hello/+(a)llo' }, + { input: 'hello/\\@(a)llo', output: 'hello/@(a)llo' }, + { input: 'hello/\\*(a)llo', output: 'hello/*(a)llo' }, + { input: 'hello/\\?(a)llo', output: 'hello/?(a)llo' }, +]; + +describe('unescapeGlob', () => { + unescapeGlobTests.forEach((test) => { + it(`should be output: ${test.output} for input: ${test.input} `, async () => { + const result = unescapeGlob(test.input); + + expect(result).toEqual(test.output); + }); + }); +}); diff --git a/redisinsight/api/src/utils/glob-pattern-helper.ts b/redisinsight/api/src/utils/glob-pattern-helper.ts new file mode 100644 index 0000000000..14e78819d0 --- /dev/null +++ b/redisinsight/api/src/utils/glob-pattern-helper.ts @@ -0,0 +1,13 @@ +const GLOB_SPEC_CHAR = ['!', '*', '?', '[', ']', '(', ')', '{', '}']; +const EXT_GLOB_SPEC_CHAR = ['@', '+']; + +export const unescapeGlob = (value: string): string => { + let result = value; + + [...GLOB_SPEC_CHAR, ...EXT_GLOB_SPEC_CHAR].forEach((char: string) => { + const regex = new RegExp('\\'.repeat(3) + char, 'g'); + result = result.replace(regex, char); + }); + + return result.replace(/\\{2}/g, '\\'); +}; diff --git a/redisinsight/api/src/utils/hosting-provider-helper.spec.ts b/redisinsight/api/src/utils/hosting-provider-helper.spec.ts new file mode 100644 index 0000000000..6c4d88570d --- /dev/null +++ b/redisinsight/api/src/utils/hosting-provider-helper.spec.ts @@ -0,0 +1,34 @@ +import { HostingProvider } from 'src/modules/core/models/database-instance.entity'; +import { getHostingProvider } from './hosting-provider-helper'; + +const getHostingProviderTests = [ + { input: '127.0.0.1', output: HostingProvider.LOCALHOST }, + { input: '0.0.0.0', output: HostingProvider.LOCALHOST }, + { input: 'localhost', output: HostingProvider.LOCALHOST }, + { input: '172.18.0.2', output: HostingProvider.LOCALHOST }, + { input: '176.87.56.244', output: HostingProvider.UNKNOWN }, + { input: '192.12.56.244', output: HostingProvider.UNKNOWN }, + { input: '255.255.56.244', output: HostingProvider.UNKNOWN }, + { input: 'redis', output: HostingProvider.UNKNOWN }, + { input: 'demo-redislabs.rlrcp.com', output: HostingProvider.RE_CLOUD }, + { + input: 'redis-16781.c273.us-east-1-2.ec2.cloud.redislabs.com', + output: HostingProvider.RE_CLOUD, + }, + { + input: 'askubuntu.mki5tz.0001.use1.cache.amazonaws.com', + output: HostingProvider.AWS, + }, + { input: 'contoso5.redis.cache.windows.net', output: HostingProvider.AZURE }, + { input: 'demo-redis-provider.unknown.com', output: HostingProvider.UNKNOWN }, +]; + +describe('getHostingProvider', () => { + getHostingProviderTests.forEach((test) => { + it(`should be output: ${test.output} for input: ${test.input} `, async () => { + const result = getHostingProvider(test.input); + + expect(result).toEqual(test.output); + }); + }); +}); diff --git a/redisinsight/api/src/utils/hosting-provider-helper.ts b/redisinsight/api/src/utils/hosting-provider-helper.ts new file mode 100644 index 0000000000..5df786bc7b --- /dev/null +++ b/redisinsight/api/src/utils/hosting-provider-helper.ts @@ -0,0 +1,22 @@ +import { HostingProvider } from 'src/modules/core/models/database-instance.entity'; +import { IP_ADDRESS_REGEX, PRIVATE_IP_ADDRESS_REGEX } from 'src/constants'; + +export const getHostingProvider = (host: string): HostingProvider => { + // Tries to detect the hosting provider from the hostname. + if (host === '0.0.0.0' || host === 'localhost') { + return HostingProvider.LOCALHOST; + } + if (IP_ADDRESS_REGEX.test(host) && PRIVATE_IP_ADDRESS_REGEX.test(host)) { + return HostingProvider.LOCALHOST; + } + if (host.endsWith('rlrcp.com') || host.endsWith('redislabs.com')) { + return HostingProvider.RE_CLOUD; + } + if (host.endsWith('cache.amazonaws.com')) { + return HostingProvider.AWS; + } + if (host.endsWith('cache.windows.net')) { + return HostingProvider.AZURE; + } + return HostingProvider.UNKNOWN; +}; diff --git a/redisinsight/api/src/utils/index.ts b/redisinsight/api/src/utils/index.ts new file mode 100644 index 0000000000..4ad3cef41a --- /dev/null +++ b/redisinsight/api/src/utils/index.ts @@ -0,0 +1,8 @@ +export * from './config'; +export * from './converter'; +export * from './glob-pattern-helper'; +export * from './catch-redis-errors'; +export * from './redis-reply-converter'; +export * from './hosting-provider-helper'; +export * from './analytics-helper'; +export * from './redis-connection-helper'; diff --git a/redisinsight/api/src/utils/logsFormatter.ts b/redisinsight/api/src/utils/logsFormatter.ts new file mode 100644 index 0000000000..271341d7b3 --- /dev/null +++ b/redisinsight/api/src/utils/logsFormatter.ts @@ -0,0 +1,54 @@ +import { format } from 'winston'; +import { pick, get, map } from 'lodash'; + +const errorWhiteListFields = [ + 'message', + 'command.name', +]; + +/** + * Get only whitelisted fields from logs when omitSensitiveData option enabled + */ +export const sensitiveDataFormatter = format((info, opts = {}) => { + let stack; + if (opts?.omitSensitiveData) { + stack = map(get(info, 'stack', []), (stackItem) => pick(stackItem, errorWhiteListFields)); + } else { + stack = map(get(info, 'stack', []), (stackItem) => { + if (stackItem?.stack) { + return { + ...stackItem, + stack: stackItem.stack, + }; + } + + return stackItem; + }); + } + + return { + ...info, + stack, + }; +}); + +export const jsonFormat = format.printf((info) => { + const logData = { + level: info.level, + timestamp: new Date().toLocaleString(), + context: info.context, + message: info.message, + stack: info.stack, + }; + return JSON.stringify(logData); +}); + +export const prettyFormat = format.printf((info) => { + const separator = ' | '; + const timestamp = new Date().toLocaleString(); + const { + level, context, message, stack, + } = info; + const logData = [timestamp, `${level}`.toUpperCase(), context, message, JSON.stringify({ stack })]; + return logData.join(separator); +}); diff --git a/redisinsight/api/src/utils/redis-connection-helper.ts b/redisinsight/api/src/utils/redis-connection-helper.ts new file mode 100644 index 0000000000..e89bae0d3c --- /dev/null +++ b/redisinsight/api/src/utils/redis-connection-helper.ts @@ -0,0 +1,22 @@ +import IORedis from 'ioredis'; +import { get } from 'lodash'; +import { CONNECTION_NAME_GLOBAL_PREFIX } from 'src/constants'; + +export const generateRedisConnectionName = (namespace: string, id: string, separator = '-') => { + try { + return [CONNECTION_NAME_GLOBAL_PREFIX, namespace, id?.substr(0, 8)].join(separator).toLowerCase(); + } catch (e) { + return CONNECTION_NAME_GLOBAL_PREFIX; + } +}; + +export const getConnectionName = (client: IORedis.Redis | IORedis.Cluster) => { + try { + if (client instanceof IORedis.Cluster) { + return get(client, 'options.redisOptions.connectionName', CONNECTION_NAME_GLOBAL_PREFIX); + } + return get(client, 'options.connectionName', CONNECTION_NAME_GLOBAL_PREFIX); + } catch (e) { + return CONNECTION_NAME_GLOBAL_PREFIX; + } +}; diff --git a/redisinsight/api/src/utils/redis-reply-converter.spec.ts b/redisinsight/api/src/utils/redis-reply-converter.spec.ts new file mode 100644 index 0000000000..f99cb2e53f --- /dev/null +++ b/redisinsight/api/src/utils/redis-reply-converter.spec.ts @@ -0,0 +1,119 @@ +import { + mockRedisClusterNodesResponse, + mockRedisServerInfoResponse, + mockStandaloneRedisInfoReply, +} from 'src/__mocks__'; +import { IRedisClusterNode, RedisClusterNodeLinkState } from 'src/models'; +import { + convertBulkStringsToObject, + convertRedisInfoReplyToObject, + parseClusterNodes, +} from './redis-reply-converter'; + +const mockRedisClusterNodesDto: IRedisClusterNode[] = [ + { + id: '07c37dfeb235213a872192d90877d0cd55635b91', + host: '127.0.0.1', + port: 30004, + replicaOf: 'e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca', + linkState: RedisClusterNodeLinkState.Connected, + slot: undefined, + }, + { + id: 'e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca', + host: '127.0.0.1', + port: 30001, + replicaOf: undefined, + linkState: RedisClusterNodeLinkState.Connected, + slot: '0-16383', + }, +]; + +const mockRedisServerInfoDto = { + redis_version: '6.0.5', + redis_mode: 'standalone', + os: 'Linux 4.15.0-1087-gcp x86_64', + arch_bits: '64', + tcp_port: '11113', + uptime_in_seconds: '1000', +}; + +const mockStandaloneRedisInfoDto = { + server: mockRedisServerInfoDto, + clients: { + connected_clients: '1', + client_longest_output_list: '0', + client_biggest_input_buf: '0', + blocked_clients: '0', + }, + memory: { + used_memory: '1000000', + used_memory_human: '1M', + used_memory_rss: '1000000', + used_memory_peak: '1000000', + used_memory_peak_human: '1M', + used_memory_lua: '37888', + mem_fragmentation_ratio: '1', + mem_allocator: 'jemalloc-5.1.0', + }, + cluster: { + cluster_enabled: '0', + }, + keyspace: { + db0: 'keys=1,expires=0,avg_ttl=0', + }, + stats: { + keyspace_hits: '1000', + keyspace_misses: '0', + }, + replication: { + role: 'master', + connected_slaves: '0', + master_repl_offset: '0', + repl_backlog_active: '0', + repl_backlog_size: '1000', + repl_backlog_first_byte_offset: '0', + repl_backlog_histlen: '0', + }, +}; + +const mockIncorrectString = '$6\r\nfoobar\r\n'; + +describe('convertBulkStringsToObject', () => { + it('should return object in a defined format', async () => { + const result = convertBulkStringsToObject(mockRedisServerInfoResponse); + + expect(result).toEqual(mockRedisServerInfoDto); + }); + it('should return empty object in case of incorrect string', async () => { + const result = convertBulkStringsToObject(mockIncorrectString); + + expect(result).toEqual({}); + }); +}); + +describe('convertRedisReplyInfoToObject', () => { + it('should return object in a defined format', async () => { + const result = convertRedisInfoReplyToObject(mockStandaloneRedisInfoReply); + + expect(result).toEqual(mockStandaloneRedisInfoDto); + }); + it('should return empty object when incorrect string passed', async () => { + const result = convertRedisInfoReplyToObject(mockIncorrectString); + + expect(result).toEqual({}); + }); +}); + +describe('parseClusterNodes', () => { + it('should return array object in a defined format', async () => { + const result = parseClusterNodes(mockRedisClusterNodesResponse); + + expect(result).toEqual(mockRedisClusterNodesDto); + }); + it('should return empty array when incorrect string passed', async () => { + const result = parseClusterNodes(mockIncorrectString); + + expect(result).toEqual([]); + }); +}); diff --git a/redisinsight/api/src/utils/redis-reply-converter.ts b/redisinsight/api/src/utils/redis-reply-converter.ts new file mode 100644 index 0000000000..e98d30be0a --- /dev/null +++ b/redisinsight/api/src/utils/redis-reply-converter.ts @@ -0,0 +1,74 @@ +import { IRedisClusterNode } from 'src/models'; + +export const convertBulkStringsToObject = ( + info: string, + entitiesSeparator = '\r\n', + KVSeparator = ':', +): any => { + const entities = info.split(entitiesSeparator); + try { + const obj = {}; + entities.forEach((line: string) => { + if (line && line.split) { + const keyValuePair = line.split(KVSeparator); + if (keyValuePair.length > 1) { + const key = keyValuePair.shift(); + obj[key] = keyValuePair.join(KVSeparator); + } + } + }); + return obj; + } catch (e) { + return {}; + } +}; + +export const convertRedisInfoReplyToObject = (info: string): any => { + try { + const result = {}; + const sections = info.match(/(?<=#\s+).*?(?=[\n,\r])/g); + const values = info.split(/#.*?[\n,\r]/g); + values.shift(); + sections.forEach((section: string, index: number) => { + result[section.toLowerCase()] = convertBulkStringsToObject( + values[index].trim(), + ); + }); + return result; + } catch (e) { + return {}; + } +}; + +export const parseClusterNodes = (info: string): IRedisClusterNode[] => { + const lines = info.split('\n'); + try { + const nodes = []; + lines.forEach((line: string) => { + if (line && line.split) { + // fields = [id, endpoint, flags, master, pingSent, pongRecv, configEpoch, linkState, slot] + const fields = line.split(' '); + const [ + id, + endpoint,, + master,,,, + linkState, + slot, + ] = fields; + const host = endpoint.split(':')[0]; + const port = endpoint.split(':')[1].split('@')[0]; + nodes.push({ + id, + host, + port: parseInt(port, 10), + replicaOf: master !== '-' ? master : undefined, + linkState, + slot, + }); + } + }); + return nodes; + } catch (e) { + return []; + } +}; diff --git a/redisinsight/api/src/validators/caCertCollision.validator.spec.ts b/redisinsight/api/src/validators/caCertCollision.validator.spec.ts new file mode 100644 index 0000000000..a9b1376275 --- /dev/null +++ b/redisinsight/api/src/validators/caCertCollision.validator.spec.ts @@ -0,0 +1,36 @@ +import { TlsDto } from 'src/modules/instances/dto/database-instance.dto'; +import { mockCaCertDto } from 'src/__mocks__'; +import { CaCertCollisionValidator } from './caCertCollision.validator'; + +const validator = new CaCertCollisionValidator(); + +describe('CaCertCollisionValidator', () => { + it('should return true for new certificates', () => { + const dto: TlsDto = { + verifyServerCert: true, + newCaCert: mockCaCertDto, + }; + expect(validator.validate(dto)).toEqual(true); + }); + it('should return true for exist certificates', () => { + const dto: TlsDto = { + verifyServerCert: true, + caCertId: 'a77b23c1-7816-4ea4-b61f-d37795a0f805', + }; + expect(validator.validate(dto)).toEqual(true); + }); + it('should return false', () => { + const dto: TlsDto = { + verifyServerCert: true, + caCertId: 'a77b23c1-7816-4ea4-b61f-d37795a0f805', + newCaCert: mockCaCertDto, + }; + expect(validator.validate(dto)).toEqual(false); + }); + + it('should return particular message by default', () => { + expect(validator.defaultMessage()).toEqual( + "Can't use caCertId and newCaCert at the same time", + ); + }); +}); diff --git a/redisinsight/api/src/validators/caCertCollision.validator.ts b/redisinsight/api/src/validators/caCertCollision.validator.ts new file mode 100644 index 0000000000..c8db23844a --- /dev/null +++ b/redisinsight/api/src/validators/caCertCollision.validator.ts @@ -0,0 +1,16 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { TlsDto } from 'src/modules/instances/dto/database-instance.dto'; + +@ValidatorConstraint({ name: 'tls-cert', async: false }) +export class CaCertCollisionValidator implements ValidatorConstraintInterface { + validate(tls: TlsDto): boolean { + return !(!!tls.caCertId && !!tls.newCaCert); + } + + defaultMessage(): string { + return "Can't use caCertId and newCaCert at the same time"; + } +} diff --git a/redisinsight/api/src/validators/clientCertCollision.validator.spec.ts b/redisinsight/api/src/validators/clientCertCollision.validator.spec.ts new file mode 100644 index 0000000000..459af5a8d7 --- /dev/null +++ b/redisinsight/api/src/validators/clientCertCollision.validator.spec.ts @@ -0,0 +1,36 @@ +import { TlsDto } from 'src/modules/instances/dto/database-instance.dto'; +import { mockClientCertDto } from 'src/__mocks__'; +import { ClientCertCollisionValidator } from './clientCertCollision.validator'; + +const validator = new ClientCertCollisionValidator(); + +describe('ClientCertCollisionValidator', () => { + it('should return true for new certificates', () => { + const dto: TlsDto = { + verifyServerCert: true, + newClientCertPair: mockClientCertDto, + }; + expect(validator.validate(dto)).toEqual(true); + }); + it('should return true for exist certificates', () => { + const dto: TlsDto = { + verifyServerCert: true, + clientCertPairId: 'a77b23c1-7816-4ea4-b61f-d37795a0f805', + }; + expect(validator.validate(dto)).toEqual(true); + }); + it('should return false', () => { + const dto: TlsDto = { + verifyServerCert: true, + clientCertPairId: 'a77b23c1-7816-4ea4-b61f-d37795a0f805', + newClientCertPair: mockClientCertDto, + }; + expect(validator.validate(dto)).toEqual(false); + }); + + it('should return particular message by default', () => { + expect(validator.defaultMessage()).toEqual( + "Can't use clientCertPairId and newClientCertPair at the same time", + ); + }); +}); diff --git a/redisinsight/api/src/validators/clientCertCollision.validator.ts b/redisinsight/api/src/validators/clientCertCollision.validator.ts new file mode 100644 index 0000000000..de1d292055 --- /dev/null +++ b/redisinsight/api/src/validators/clientCertCollision.validator.ts @@ -0,0 +1,17 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { TlsDto } from 'src/modules/instances/dto/database-instance.dto'; + +@ValidatorConstraint({ name: 'tls-cert', async: false }) +export class ClientCertCollisionValidator +implements ValidatorConstraintInterface { + validate(tls: TlsDto): boolean { + return !(!!tls.clientCertPairId && !!tls.newClientCertPair); + } + + defaultMessage(): string { + return "Can't use clientCertPairId and newClientCertPair at the same time"; + } +} diff --git a/redisinsight/api/src/validators/index.ts b/redisinsight/api/src/validators/index.ts new file mode 100644 index 0000000000..fb1a1267da --- /dev/null +++ b/redisinsight/api/src/validators/index.ts @@ -0,0 +1,3 @@ +export * from './caCertCollision.validator'; +export * from './clientCertCollision.validator'; +export * from './serializedJson.validator'; diff --git a/redisinsight/api/src/validators/serializedJson.validator.spec.ts b/redisinsight/api/src/validators/serializedJson.validator.spec.ts new file mode 100644 index 0000000000..9313192749 --- /dev/null +++ b/redisinsight/api/src/validators/serializedJson.validator.spec.ts @@ -0,0 +1,64 @@ +import { SerializedJsonValidator } from 'src/validators/serializedJson.validator'; + +const validator = new SerializedJsonValidator(); + +const toValidate = [ + { + name: 'Boolean', + value: true, + }, + { + name: 'Null', + value: null, + }, + { + name: 'Number', + value: 12, + }, + { + name: 'String', + value: 'some string', + }, + { + name: 'Empty String', + value: '', + }, + { + name: 'Object', + value: { some: 'object', width: ['diff', 'types', 0, 1, null] }, + }, + { + name: 'Array', + value: ['diff', 'types', 0, 1, null, { some: 'obj' }], + }, +]; + +describe('SerializedJsonValidator', () => { + toValidate.forEach((testCase) => { + it(`return true when serialized (${testCase.name})`, () => { + expect(validator.validate(JSON.stringify(testCase.value))).toEqual(true); + }); + }); + + toValidate.forEach((testCase) => { + switch (testCase.name) { + case 'Boolean': + case 'Number': + case 'Null': + it(`return true when not serializes (${testCase.name})`, () => { + expect(validator.validate(testCase.value)).toEqual(true); + }); + break; + default: + it(`return false when not serializes (${testCase.name})`, () => { + expect(validator.validate(testCase.value)).toEqual(false); + }); + } + }); + + it('should return particular message by default', () => { + expect(validator.defaultMessage({ property: 'path' })).toEqual( + 'path should be a correct serialized json string', + ); + }); +}); diff --git a/redisinsight/api/src/validators/serializedJson.validator.ts b/redisinsight/api/src/validators/serializedJson.validator.ts new file mode 100644 index 0000000000..f23d24a0f9 --- /dev/null +++ b/redisinsight/api/src/validators/serializedJson.validator.ts @@ -0,0 +1,20 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'serialized-json', async: false }) +export class SerializedJsonValidator implements ValidatorConstraintInterface { + validate(data: any): boolean { + try { + JSON.parse(data); + } catch { + return false; + } + return true; + } + + defaultMessage(data): string { + return `${data.property} should be a correct serialized json string`; + } +} diff --git a/redisinsight/api/test/api/api.deps.init.ts b/redisinsight/api/test/api/api.deps.init.ts new file mode 100644 index 0000000000..ec37f08e69 --- /dev/null +++ b/redisinsight/api/test/api/api.deps.init.ts @@ -0,0 +1,9 @@ +import { depsInit } from './deps'; + +/** + * Mocha hooks + * Initiate dependencies before all tests + */ +export const mochaHooks = async () => { + await depsInit(); +}; diff --git a/redisinsight/api/test/api/api.tsconfig.json b/redisinsight/api/test/api/api.tsconfig.json new file mode 100644 index 0000000000..630e375f49 --- /dev/null +++ b/redisinsight/api/test/api/api.tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "es2017", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "paths": { + "src/*": [ + "../../src/*" + ] + } + } +} diff --git a/redisinsight/api/test/api/cli/POST-instance-id-cli-uuid-send_cluster_command.test.ts b/redisinsight/api/test/api/cli/POST-instance-id-cli-uuid-send_cluster_command.test.ts new file mode 100644 index 0000000000..d71e01b813 --- /dev/null +++ b/redisinsight/api/test/api/cli/POST-instance-id-cli-uuid-send_cluster_command.test.ts @@ -0,0 +1,278 @@ +import { + expect, + describe, + it, + before, + Joi, + _, + deps, + validateApiCall, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID, uuid = constants.TEST_CLI_UUID_1) => + request(server).post(`/instance/${instanceId}/cli/${uuid}/send-cluster-command`); + +// input data schema +const dataSchema = Joi.object({ + command: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), + role: Joi.string().required().valid('ALL', 'MASTER', 'SLAVE'), + outputFormat: Joi.string().allow(null).valid('TEXT', 'RAW'), +}).strict(); + +const validInputData = { + command: 'set foo bar', + role: 'ALL', +}; + +const responseSchema = Joi.array().items(Joi.object().keys({ + response: Joi.string().required(), + status: Joi.string().required(), + node: Joi.object().keys({ + host: Joi.string().required(), + port: Joi.number().integer().required(), + }) +}).required()); + +const responseRawSchema = Joi.array().items(Joi.object().keys({ + response: Joi.any().required(), + status: Joi.string().required(), + node: Joi.object().keys({ + host: Joi.string().required(), + port: Joi.number().integer().required(), + }) +}).required()); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/cli/:uuid/send-cluster-command', () => { + requirements('rte.type=CLUSTER'); + + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should create string', + data: { + command: `set ${constants.TEST_STRING_KEY_1} ${constants.TEST_STRING_VALUE_1}`, + role: 'ALL', + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_STRING_KEY_1)).to.eql(0); + }, + after: async () => { + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(constants.TEST_STRING_VALUE_1); + } + }, + { + name: 'Should get string', + data: { + command: `get ${constants.TEST_STRING_KEY_1}`, + role: 'ALL', + }, + responseSchema, + checkFn: async ({ body }) => { + expect((body.filter(shard => shard.status === 'success'))[0].response) + .to.have.string(constants.TEST_STRING_VALUE_1) + } + }, + { + name: 'Should remove string', + data: { + command: `del ${constants.TEST_STRING_KEY_1}`, + role: 'ALL', + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_STRING_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + + describe('Single Node', () => { + const node = rte.env.nodes[0]; + const nodeOptions = { + host: node?.host, + port: node?.port, + enableRedirection: true, + }; + describe('String', () => { + [ + { + name: 'Should create string', + data: { + command: `set ${constants.TEST_STRING_KEY_1} ${constants.TEST_STRING_VALUE_1}`, + role: 'ALL', + nodeOptions + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_STRING_KEY_1)).to.eql(0); + }, + checkFn: ({ body }) => { + expect(body).to.have.a.lengthOf(1); + }, + after: async () => { + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(constants.TEST_STRING_VALUE_1); + } + }, + { + name: 'Should get string', + data: { + command: `get ${constants.TEST_STRING_KEY_1}`, + role: 'ALL', + nodeOptions + }, + responseSchema, + checkFn: ({ body }) => { + expect(body).to.have.a.lengthOf(1); + expect(body[0].response).to.have.string(constants.TEST_STRING_VALUE_1); + } + }, + { + name: 'Should remove string', + data: { + command: `del ${constants.TEST_STRING_KEY_1}`, + role: 'ALL', + nodeOptions + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_STRING_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }) + describe('Raw output', () => { + [ + { + name: 'Should return a string type response', + data: { + command: `set ${constants.TEST_STRING_KEY_1} ${constants.TEST_STRING_VALUE_1}`, + outputFormat: 'RAW', + role: 'ALL', + nodeOptions + }, + responseRawSchema, + checkFn: ({ body }) => { + expect(body).to.have.a.lengthOf(1); + expect(body[0].response).to.eql('OK') + } + }, + { + name: 'Should return a number type response', + data: { + command: `del ${constants.TEST_STRING_KEY_1}`, + outputFormat: 'RAW', + role: 'ALL', + nodeOptions + }, + responseRawSchema, + checkFn: ({ body }) => { + expect(body).to.have.a.lengthOf(1); + expect(body[0].response).to.be.a('number') + } + }, + { + name: 'Should return an array type response', + data: { + command: `lrange ${constants.TEST_LIST_KEY_1} 0 100`, + outputFormat: 'RAW', + role: 'ALL', + nodeOptions + }, + responseRawSchema, + before: async () => { + await rte.client.lpush(constants.TEST_LIST_KEY_1, constants.TEST_LIST_ELEMENT_1, constants.TEST_LIST_ELEMENT_2) + }, + after: async () => { + await rte.client.del(constants.TEST_LIST_KEY_1) + }, + checkFn: ({ body }) => { + expect(body).to.have.a.lengthOf(1); + expect(body[0].response).to.eql([ + constants.TEST_LIST_ELEMENT_2, + constants.TEST_LIST_ELEMENT_1, + ]); + } + }, + { + name: 'Should return an object type response', + data: { + command: `hgetall ${constants.TEST_HASH_KEY_1}`, + outputFormat: 'RAW', + role: 'ALL', + nodeOptions + }, + responseRawSchema, + before: async () => { + await rte.client.hset(constants.TEST_HASH_KEY_1, [constants.TEST_HASH_FIELD_1_NAME, constants.TEST_HASH_FIELD_1_VALUE]); + }, + after: async () => { + await rte.client.del(constants.TEST_HASH_KEY_1); + }, + checkFn: ({ body }) => { + expect(body).to.have.a.lengthOf(1); + expect(body[0].response).to.be.an('object'); + expect(body[0].response).to.deep.eql({[constants.TEST_HASH_FIELD_1_NAME]: constants.TEST_HASH_FIELD_1_VALUE}); + } + }, + ].map(mainCheckFn); + }) + }); + + describe('Commands redirection', () => { + const nodes = rte.env.nodes; + _.map(nodes, (node) => ({ + name: `Should create string with redirection if needed (${node.host}:${node.port})`, + data: { + command: `set ${constants.TEST_STRING_KEY_1} ${node.host}`, + role: 'ALL', + nodeOptions: { + host: node.host, + port: node.port, + enableRedirection: true, + } + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].response === '"OK"' || body[0].response.toLowerCase().includes('redirected')).to.eql(true); + }, + after: async () => { + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(node.host); + } + })).map(mainCheckFn); + }) +}); diff --git a/redisinsight/api/test/api/cli/POST-instance-id-cli-uuid-send_command.test.ts b/redisinsight/api/test/api/cli/POST-instance-id-cli-uuid-send_command.test.ts new file mode 100644 index 0000000000..d004b1cad8 --- /dev/null +++ b/redisinsight/api/test/api/cli/POST-instance-id-cli-uuid-send_command.test.ts @@ -0,0 +1,919 @@ +import { + expect, + describe, + it, + before, + Joi, + _, + deps, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall, + requirements, +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID, uuid = constants.TEST_CLI_UUID_1) => + request(server).post(`/instance/${instanceId}/cli/${uuid}/send-command`); + +// input data schema +const dataSchema = Joi.object({ + command: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), + outputFormat: Joi.string().allow(null).valid('TEXT', 'RAW'), +}).strict(); + +const validInputData = { + command: 'set foo bar', +}; + +const responseSchema = Joi.object().keys({ + response: Joi.string().required(), + status: Joi.string().required(), +}).required(); + +const responseRawSchema = Joi.object().keys({ + response: Joi.any().required(), + status: Joi.string().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/cli/:uuid/send-command', () => { + requirements('rte.type=STANDALONE'); + + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + describe('String', () => { + [ + { + name: 'Should create string', + data: { + command: `set ${constants.TEST_STRING_KEY_1} ${constants.TEST_STRING_VALUE_1}`, + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_STRING_KEY_1)).to.eql(0); + }, + after: async () => { + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(constants.TEST_STRING_VALUE_1); + } + }, + { + name: 'Should get string', + data: { + command: `get ${constants.TEST_STRING_KEY_1}`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(constants.TEST_STRING_VALUE_1) + } + }, + { + name: 'Should remove string', + data: { + command: `del ${constants.TEST_STRING_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_STRING_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('List', () => { + [ + { + name: 'Should create list', + data: { + command: `lpush ${constants.TEST_LIST_KEY_1} ${constants.TEST_LIST_ELEMENT_1} ${constants.TEST_LIST_ELEMENT_2}`, + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_LIST_KEY_1)).to.eql(0); + }, + after: async () => { + expect(await rte.client.lrange(constants.TEST_LIST_KEY_1, 0, 100)).to.eql([ + constants.TEST_LIST_ELEMENT_2, + constants.TEST_LIST_ELEMENT_1, + ]); + } + }, + { + name: 'Should get list', + data: { + command: `lrange ${constants.TEST_LIST_KEY_1} 0 100`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) "${constants.TEST_LIST_ELEMENT_2}"`); + expect(body.response).to.have.string(`2) "${constants.TEST_LIST_ELEMENT_1}"`); + } + }, + { + name: 'Should remove list', + data: { + command: `del ${constants.TEST_LIST_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_LIST_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('Set', () => { + [ + { + name: 'Should create set', + data: { + command: `sadd ${constants.TEST_SET_KEY_1} ${constants.TEST_SET_MEMBER_1} ${constants.TEST_SET_MEMBER_2}`, + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_SET_KEY_1)).to.eql(0); + }, + after: async () => { + const [cursor, set] = await rte.client.sscan(constants.TEST_SET_KEY_1, 0); + expect(cursor).to.eql('0'); + expect(set.length).to.eql(2); + expect(set.join()).to.include(constants.TEST_SET_MEMBER_1); + expect(set.join()).to.include(constants.TEST_SET_MEMBER_2); + }, + }, + { + name: 'Should get set', + data: { + command: `sscan ${constants.TEST_SET_KEY_1} 0 count 100`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(constants.TEST_SET_MEMBER_2); + expect(body.response).to.have.string(constants.TEST_SET_MEMBER_1); + } + }, + { + name: 'Should remove list', + data: { + command: `del ${constants.TEST_SET_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_SET_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('ZSet', () => { + [ + { + name: 'Should create zset', + data: { + command: `zadd ${constants.TEST_ZSET_KEY_1} 1 ${constants.TEST_ZSET_MEMBER_1} 2 ${constants.TEST_ZSET_MEMBER_2}`, + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_ZSET_KEY_1)).to.eql(0); + }, + after: async () => { + expect(await rte.client.zrange(constants.TEST_ZSET_KEY_1, 0, 100)).to.deep.eql([ + constants.TEST_ZSET_MEMBER_1, + constants.TEST_ZSET_MEMBER_2, + ]); + }, + }, + { + name: 'Should get zset', + data: { + command: `zrange ${constants.TEST_ZSET_KEY_1} 0 100`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) "${constants.TEST_ZSET_MEMBER_1}"`); + expect(body.response).to.have.string(`2) "${constants.TEST_ZSET_MEMBER_2}"`); + } + }, + { + name: 'Should remove zset', + data: { + command: `del ${constants.TEST_ZSET_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_ZSET_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('Hash', () => { + [ + { + name: 'Should create hash', + data: { + command: `hset ${constants.TEST_HASH_KEY_1} ${constants.TEST_HASH_FIELD_1_NAME} ${constants.TEST_HASH_FIELD_1_VALUE}`, + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_HASH_KEY_1)).to.eql(0); + }, + after: async () => { + expect(await rte.client.hgetall(constants.TEST_HASH_KEY_1)).to.deep.eql({ + [constants.TEST_HASH_FIELD_1_NAME]: constants.TEST_HASH_FIELD_1_VALUE, + }); + }, + }, + { + name: 'Should get hash', + data: { + command: `hgetall ${constants.TEST_HASH_KEY_1}`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) "${constants.TEST_HASH_FIELD_1_NAME}"`); + expect(body.response).to.have.string(`2) "${constants.TEST_HASH_FIELD_1_VALUE}"`); + } + }, + { + name: 'Should remove hash', + data: { + command: `del ${constants.TEST_HASH_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_HASH_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('ReJSON-RL', () => { + requirements('rte.modules.rejson'); + [ + { + name: 'Should create json', + data: { + command: `json.set ${constants.TEST_REJSON_KEY_1} . "{\\"field\\":\\"value\\"}"`, + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_REJSON_KEY_1)).to.eql(0); + }, + after: async () => { + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_1, '.')).to.eql('{"field":"value"}'); + }, + }, + { + name: 'Should get json', + data: { + command: `json.get ${constants.TEST_REJSON_KEY_1} .field`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`value`); + expect(body.response).to.have.string(`\\"`); + } + }, + { + name: 'Should remove json', + data: { + command: `json.del ${constants.TEST_REJSON_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_REJSON_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('TSDB-TYPE', () => { + requirements('rte.modules.timeseries'); + [ + { + name: 'Should create ts', + data: { + command: `ts.create ${constants.TEST_TS_KEY_1} ${constants.TEST_TS_VALUE_1} ${constants.TEST_TS_VALUE_2}`, + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_TS_KEY_1)).to.eql(0); + }, + after: async () => { + expect(await rte.client.exists(constants.TEST_TS_KEY_1)).to.eql(1); + }, + }, + { + name: 'Should add to ts', + data: { + command: `ts.add ${constants.TEST_TS_KEY_1} ${constants.TEST_TS_TIMESTAMP_1} ${constants.TEST_TS_VALUE_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.data.executeCommand('ts.get', constants.TEST_TS_KEY_1)).to.eql([ + constants.TEST_TS_TIMESTAMP_1, + constants.TEST_TS_VALUE_1.toString(), + ]); + }, + }, + { + name: 'Should get ts', + data: { + command: `ts.get ${constants.TEST_TS_KEY_1}`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`2) "10"`); + } + }, + { + name: 'Should remove ts', + data: { + command: `del ${constants.TEST_TS_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_TS_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('Graph', () => { + requirements('rte.modules.graph'); + [ + { + name: 'Should create graph', + data: { + command: `graph.query ${constants.TEST_GRAPH_KEY_1} "CREATE (n1)"`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) "Nodes created: 1"`); + }, + before: async () => { + expect(await rte.client.exists(constants.TEST_GRAPH_KEY_1)).to.eql(0); + }, + after: async () => { + expect(await rte.client.exists(constants.TEST_GRAPH_KEY_1)).to.eql(1); + }, + }, + { + name: 'Should get graph', + data: { + command: `graph.query ${constants.TEST_GRAPH_KEY_1} "MATCH (n1) RETURN n1"`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) "n1"`); + } + }, + { + name: 'Should remove graph', + data: { + command: `del ${constants.TEST_GRAPH_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_GRAPH_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('RediSearch v2', () => { + describe('Hash', () => { + requirements('rte.modules.search', 'rte.modules.search.version>=20000'); + [ + { + name: 'Should create index', + data: { + command: `ft.create ${constants.TEST_SEARCH_HASH_INDEX_1} ON HASH + PREFIX 1 ${constants.TEST_SEARCH_HASH_KEY_PREFIX_1} NOOFFSETS SCHEMA title TEXT WEIGHT 5.0`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string('"OK"'); + }, + before: async () => { + expect(await rte.client.send_command('ft._list')).to.not.include(constants.TEST_SEARCH_HASH_INDEX_1); + }, + after: async () => { + expect(await rte.client.send_command(`ft._list`)).to.include(constants.TEST_SEARCH_HASH_INDEX_1); + }, + }, + { + name: 'Should return the list of all existing indexes.', + data: { + command: `ft._list`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.include(constants.TEST_SEARCH_HASH_INDEX_1) + }, + }, + { + name: 'Should return index info', + data: { + outputFormat: 'RAW', + command: `ft.info ${constants.TEST_SEARCH_HASH_INDEX_1}`, + }, + responseRawSchema, + checkFn: ({ body }) => { + expect(body.response[0]).to.eql('index_name'); + expect(body.response[1]).to.eql(constants.TEST_SEARCH_HASH_INDEX_1); + expect(body.response[2]).to.eql('index_options'); + expect(body.response[3]).to.eql(['NOOFFSETS']); + expect(body.response[4]).to.eql('index_definition'); + expect(_.take(body.response[5], 4)).to.eql( ['key_type', 'HASH', 'prefixes', [constants.TEST_SEARCH_HASH_KEY_PREFIX_1]]); + expect(body.response[6]).to.eql('fields'); + expect(body.response[7]).to.deep.include( [ 'title', 'type', 'TEXT', 'WEIGHT', '5' ]); + }, + }, + { + name: 'Should find documents', + data: { + command: `ft.search ${constants.TEST_SEARCH_HASH_INDEX_1} "hello world"`, + }, + responseSchema, + before: async () => { + for (let i = 0; i < 10; i++) { + await rte.client.hset(`${constants.TEST_SEARCH_HASH_KEY_PREFIX_1}${i}`, 'title', `hello world ${i}`) + } + }, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) 10`); + } + }, + { + name: 'Should aggregate documents by uniq @title', + data: { + command: `ft.aggregate ${constants.TEST_SEARCH_HASH_INDEX_1} * GROUPBY 1 @title`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) 10`); + } + }, + { + name: 'Should remove index', + data: { + command: `ft.dropindex ${constants.TEST_SEARCH_HASH_INDEX_1} DD`, + }, + responseSchema, + after: async () => { + expect(await rte.client.send_command('ft._list')).to.not.include(constants.TEST_SEARCH_HASH_INDEX_1); + } + }, + ].map(mainCheckFn); + }) + describe('JSON', () => { + requirements( + 'rte.modules.search', + 'rte.modules.rejson', + 'rte.modules.search.version>=20200', + 'rte.modules.rejson>=20000' + ); + [ + { + name: 'Should create index', + data: { + command: `ft.create ${constants.TEST_SEARCH_JSON_INDEX_1} ON JSON NOOFFSETS + SCHEMA $.user.name AS name TEXT`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string('"OK"'); + }, + before: async () => { + expect(await rte.client.send_command('ft._list')).to.not.include(constants.TEST_SEARCH_JSON_INDEX_1); + }, + after: async () => { + expect(await rte.client.send_command(`ft._list`)).to.include(constants.TEST_SEARCH_JSON_INDEX_1); + }, + }, + { + name: 'Should return index info', + data: { + outputFormat: 'RAW', + command: `ft.info ${constants.TEST_SEARCH_JSON_INDEX_1}`, + }, + responseRawSchema, + checkFn: ({ body }) => { + expect(body.response[0]).to.eql('index_name'); + expect(body.response[1]).to.eql(constants.TEST_SEARCH_JSON_INDEX_1); + expect(body.response[2]).to.eql('index_options'); + expect(body.response[3]).to.eql(['NOOFFSETS']); + expect(body.response[4]).to.eql('index_definition'); + expect(_.take(body.response[5], 4)).to.eql( ['key_type', 'JSON', 'prefixes', ['']]); + expect(body.response[6]).to.eql('fields'); + expect(body.response[7]).to.deep.include( [ 'name', 'type', 'TEXT', 'WEIGHT', '1' ]); + }, + }, + { + name: 'Should find documents', + data: { + command: `ft.search ${constants.TEST_SEARCH_JSON_INDEX_1} "@name:(John)"`, + }, + responseSchema, + before: async () => { + for (let i = 0; i < 10; i++) { + await rte.client.send_command( + 'json.set', + [`${constants.TEST_SEARCH_JSON_KEY_PREFIX_1}${i}`, '$', `{"user":{"name":"John Smith${i}"}}`] + ) + } + }, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) 10`); + } + }, + { + name: 'Should aggregate documents by uniq @name', + data: { + command: `ft.aggregate ${constants.TEST_SEARCH_JSON_INDEX_1} * GROUPBY 1 @name`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) 10`); + } + }, + { + name: 'Should remove index', + data: { + command: `ft.dropindex ${constants.TEST_SEARCH_JSON_INDEX_1} DD`, + }, + responseSchema, + after: async () => { + expect(await rte.client.send_command('ft._list')).to.not.include(constants.TEST_SEARCH_JSON_INDEX_1); + } + }, + ].map(mainCheckFn); + }) + }); + describe('RediSearch v1', () => { + describe('Hash', () => { + requirements('rte.modules.ft', 'rte.modules.ft.version>=10615'); + [ + { + name: 'Should create index', + data: { + command: `ft.create ${constants.TEST_SEARCH_HASH_INDEX_1} NOOFFSETS SCHEMA title TEXT WEIGHT 5.0`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string('"OK"'); + }, + before: async () => { + let errorMessage = ''; + try { + await rte.client.send_command('ft.info', [constants.TEST_SEARCH_HASH_INDEX_1]) + } catch ({message}) { + errorMessage = message; + } + expect(errorMessage).to.eql('Unknown Index name') + }, + after: async () => { + expect(await rte.client.send_command('ft.info', [constants.TEST_SEARCH_HASH_INDEX_1])) + .to.include(constants.TEST_SEARCH_HASH_INDEX_1) + }, + }, + { + name: 'Should return index info', + data: { + outputFormat: 'RAW', + command: `ft.info ${constants.TEST_SEARCH_HASH_INDEX_1}`, + }, + responseRawSchema, + checkFn: ({ body }) => { + expect(body.response[0]).to.eql('index_name'); + expect(body.response[1]).to.eql(constants.TEST_SEARCH_HASH_INDEX_1); + expect(body.response[2]).to.eql('index_options'); + expect(body.response[3]).to.eql(['NOOFFSETS']); + expect(body.response[4]).to.eql('fields'); + expect(body.response[5]).to.deep.include( [ 'title', 'type', 'TEXT', 'WEIGHT', '5' ]); + }, + }, + { + name: 'Should find documents', + data: { + command: `ft.search ${constants.TEST_SEARCH_HASH_INDEX_1} "hello world"`, + }, + responseSchema, + before: async () => { + for (let i = 0; i < 10; i++) { + await rte.client.send_command( + 'ft.add', + [constants.TEST_SEARCH_HASH_INDEX_1, `${constants.TEST_SEARCH_HASH_KEY_PREFIX_1}${i}`, '1.0', 'FIELDS', 'title', 'hello world'] + ) + } + }, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) 10`); + } + }, + { + name: 'Should remove index', + data: { + command: `ft.drop ${constants.TEST_SEARCH_HASH_INDEX_1}`, + }, + responseSchema, + after: async () => { + let errorMessage = ''; + try { + await rte.client.send_command('ft.info', [constants.TEST_SEARCH_HASH_INDEX_1]) + } catch ({message}) { + errorMessage = message; + } + expect(errorMessage).to.eql('Unknown Index name') + } + }, + ].map(mainCheckFn); + }) + }); + describe('Stream', () => { + requirements('rte.version>=5.0'); + [ + { + name: 'Should create stream', + data: { + command: `xadd ${constants.TEST_STREAM_KEY_1} * ${constants.TEST_STREAM_DATA_1} ${constants.TEST_STREAM_DATA_2}`, + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_STREAM_KEY_1)).to.eql(0); + }, + after: async () => { + expect(await rte.client.exists(constants.TEST_STREAM_KEY_1)).to.eql(1); + }, + }, + { + name: 'Should get stream', + data: { + command: `xrange ${constants.TEST_STREAM_KEY_1} - +`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.have.string(`1) "${constants.TEST_STREAM_DATA_1}"`); + expect(body.response).to.have.string(`2) "${constants.TEST_STREAM_DATA_2}"`); + } + }, + { + name: 'Should remove stream', + data: { + command: `del ${constants.TEST_STREAM_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_STREAM_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('Bad commands', () => { + [ + { + name: 'Should return error if invalid command sent', + data: { + command: `setx ${constants.TEST_STRING_KEY_1} ${constants.TEST_STRING_VALUE_1}`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.status).to.eql('fail'); + expect(body.response).to.include('ERR unknown command'); + } + }, + { + name: 'Should return error if try to run unsupported command (monitor)', + data: { + command: `monitor`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.status).to.eql('fail'); + expect(body.response).to.include('command is not supported by the RedisInsight CLI'); + } + }, + { + name: 'Should return error if try to run unsupported command (subscribe)', + data: { + command: `subscribe`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.status).to.eql('fail'); + expect(body.response).to.include('command is not supported by the RedisInsight CLI'); + } + }, + { + name: 'Should return error if try to run unsupported command (psubscribe)', + data: { + command: `psubscribe`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.status).to.eql('fail'); + expect(body.response).to.include('command is not supported by the RedisInsight CLI'); + } + }, + { + name: 'Should return error if try to run unsupported command (sync)', + data: { + command: `sync`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.status).to.eql('fail'); + expect(body.response).to.include('command is not supported by the RedisInsight CLI'); + } + }, + { + name: 'Should return error if try to run unsupported command (psync)', + data: { + command: `psync`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.status).to.eql('fail'); + expect(body.response).to.include('command is not supported by the RedisInsight CLI'); + } + }, + { + name: 'Should return error if try to run unsupported command (script debug)', + data: { + command: `script debug`, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.status).to.eql('fail'); + expect(body.response).to.include('command is not supported by the RedisInsight CLI'); + } + }, + ].map(mainCheckFn); + }); + describe('Blocking commands', () => { + [ + { + name: 'Should use blocking command (unblock by cli command)', + data: { + command: `blpop ${constants.TEST_LIST_KEY_2} 0`, + }, + responseSchema, + before: async function () { + // unblock command after 1 sec + setTimeout(async () => { + const clients = (await rte.client.client('list')).split('\n'); + const currentClient = clients.filter((client) => client.toLowerCase().indexOf('cmd=blpop') > -1); + expect(currentClient.length).to.eql(1); + + const blockedClientId = (currentClient[0].match(/^id=(\d+)/))[1]; + await rte.client.client('unblock', blockedClientId); + }, 5000) + }, + }, + { + name: 'Should use blocking command (unblock by adding element)', + data: { + command: `blpop ${constants.TEST_LIST_KEY_2} 0`, + }, + responseSchema, + before: async function () { + // unblock command after 1 sec + setTimeout(async () => { + await rte.client.lpush(constants.TEST_LIST_KEY_2, 'element'); + }, 5000) + }, + }, + { + name: 'Should use blocking command (unblock by removing client through API)', + data: { + command: `blpop ${constants.TEST_LIST_KEY_2} 0`, + }, + statusCode: 500, // todo: is it as designed? + responseBody: { + statusCode: 500, + message: 'Connection is closed.', + error: 'Internal Server Error', + }, + before: async function () { + // unblock command after 1 sec + setTimeout(async () => { + await request(server).delete(`/instance/${constants.TEST_INSTANCE_ID}/cli/${constants.TEST_CLI_UUID_1}`); + }, 1000) + }, + }, + { + name: 'Should remove list', + data: { + command: `del ${constants.TEST_LIST_KEY_1}`, + }, + responseSchema, + after: async () => { + expect(await rte.client.exists(constants.TEST_LIST_KEY_1)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); + describe('Human readable commands', () => { + [ + { + name: 'Should return server info in correct text format', + data: { + command: `info server`, + outputFormat: 'TEXT', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.response).to.include('# Server\r\n') + } + }, + ].map(mainCheckFn); + }); + }); + + describe('Raw output', () => { + [ + { + name: 'Should return a string type response', + data: { + command: `set ${constants.TEST_STRING_KEY_1} ${constants.TEST_STRING_VALUE_1}`, + outputFormat: 'RAW' + }, + responseRawSchema, + checkFn: ({ body }) => { + expect(body.response).to.eql('OK') + } + }, + { + name: 'Should return a number type response', + data: { + command: `del ${constants.TEST_STRING_KEY_1}`, + outputFormat: 'RAW' + }, + responseRawSchema, + checkFn: ({ body }) => { + expect(body.response).to.be.a('number') + } + }, + { + name: 'Should return an array type response', + data: { + command: `lrange ${constants.TEST_LIST_KEY_1} 0 100`, + outputFormat: 'RAW' + }, + responseRawSchema, + before: async () => { + await rte.client.lpush(constants.TEST_LIST_KEY_1, constants.TEST_LIST_ELEMENT_1, constants.TEST_LIST_ELEMENT_2) + }, + after: async () => { + await rte.client.del(constants.TEST_LIST_KEY_1) + }, + checkFn: ({ body }) => { + expect(body.response).to.eql([ + constants.TEST_LIST_ELEMENT_2, + constants.TEST_LIST_ELEMENT_1, + ]) + } + }, + { + name: 'Should return an object type response', + data: { + command: `hgetall ${constants.TEST_HASH_KEY_1}`, + outputFormat: 'RAW' + }, + responseRawSchema, + before: async () => { + await rte.client.hset(constants.TEST_HASH_KEY_1, [constants.TEST_HASH_FIELD_1_NAME, constants.TEST_HASH_FIELD_1_VALUE]) + }, + after: async () => { + await rte.client.del(constants.TEST_HASH_KEY_1) + }, + checkFn: ({ body }) => { + expect(body.response).to.be.an('object'); + expect(body.response).to.deep.eql({[constants.TEST_HASH_FIELD_1_NAME]: constants.TEST_HASH_FIELD_1_VALUE}); + } + }, + ].map(mainCheckFn); + }) +}); diff --git a/redisinsight/api/test/api/cli/POST-instance-id-cli.test.ts b/redisinsight/api/test/api/cli/POST-instance-id-cli.test.ts new file mode 100644 index 0000000000..d4ff912ff9 --- /dev/null +++ b/redisinsight/api/test/api/cli/POST-instance-id-cli.test.ts @@ -0,0 +1,53 @@ +import { + describe, + it, + before, + Joi, + deps, + validateApiCall, + requirements, +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/cli`); + +const responseSchema = Joi.object().keys({ + uuid: Joi.string().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/cli', () => { + requirements('rte.type=STANDALONE'); + + before(rte.data.truncate); + + describe('Common', () => { + [ + { + name: 'Should create new cli client', + statusCode: 201, + responseSchema, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts b/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts new file mode 100644 index 0000000000..19e40963a4 --- /dev/null +++ b/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts @@ -0,0 +1,88 @@ +import { + describe, + it, + deps, + validateApiCall, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + Joi, +} from '../deps'; +const { request, server, constants } = deps; + +const endpoint = () => request(server).post(`/redis-enterprise/cloud/get-account`); + +const dataSchema = Joi.object({ + apiKey: Joi.string().required(), + apiSecretKey: Joi.string().required(), +}).strict(); + +const validInputData = { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, +} + +const responseSchema = Joi.object().keys({ + accountId: Joi.number().required(), + accountName: Joi.string().required(), + ownerName: Joi.string().required(), + ownerEmail: Joi.string().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('POST /redis-enterprise/cloud/get-account', () => { + requirements('rte.cloud'); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should get account info', + data: { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, + }, + responseSchema, + }, + { + name: 'Should throw Forbidden error when api key is incorrect', + data: { + apiKey: 'wrong-api-key', + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + + }, + { + name: 'Should throw Forbidden error when api secret key is incorrect', + data: { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: 'wrong-api-secret-key', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_databases.test.ts b/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_databases.test.ts new file mode 100644 index 0000000000..e284423543 --- /dev/null +++ b/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_databases.test.ts @@ -0,0 +1,116 @@ +import { + describe, + it, + before, + deps, + validateApiCall, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + expect, + _, + Joi, +} from '../deps'; +const { request, server, constants } = deps; + +const endpoint = () => request(server).post(`/redis-enterprise/cloud/get-databases`); + +const dataSchema = Joi.object({ + apiKey: Joi.string().required(), + apiSecretKey: Joi.string().required(), + subscriptionIds: Joi.number().allow(true).required(), // todo: review transform rules +}).strict(); + +const validInputData = { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, + subscriptionIds: 1 +} + +const responseSchema = Joi.array().items(Joi.object().keys({ + subscriptionId: Joi.number().required(), + databaseId: Joi.number().required(), + name: Joi.string().required(), + publicEndpoint: Joi.string().required(), + status: Joi.string().required(), + sslClientAuthentication: Joi.boolean().required(), + modules: Joi.array().required(), + options: Joi.object().required(), +})).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('POST /redis-enterprise/cloud/get-databases', () => { + requirements('rte.cloud'); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', async () => { + [ + { + name: 'Should get databases list inside subscription', + data: { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, + subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] + }, + responseSchema, + checkFn: ({ body }) => { + const database = _.find(body, { name: constants.TEST_CLOUD_DATABASE_NAME }); + expect(database.publicEndpoint).to.eql(`${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`); + }, + }, + { + name: 'Should throw Forbidden error when api key is incorrect', + data: { + apiKey: 'wrong-api-key', + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, + subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + }, + { + name: 'Should throw Forbidden error when api secret key is incorrect', + data: { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: 'wrong-api-secret-key', + subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + }, + { + name: 'Should throw Not Found error when subscription id is not found', + data: { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, + subscriptionIds: [1] + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + }, + + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts b/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts new file mode 100644 index 0000000000..64b6953382 --- /dev/null +++ b/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts @@ -0,0 +1,95 @@ +import { + describe, + it, + deps, + validateApiCall, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + expect, + _, + Joi, +} from '../deps'; +const { request, server, constants } = deps; + +const endpoint = () => request(server).post(`/redis-enterprise/cloud/get-subscriptions`); + +const dataSchema = Joi.object({ + apiKey: Joi.string().required(), + apiSecretKey: Joi.string().required(), +}).strict(); + +const validInputData = { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, +} + +const responseSchema = Joi.array().items(Joi.object().keys({ + id: Joi.number().required(), + name: Joi.string().required(), + numberOfDatabases: Joi.number().required(), + status: Joi.string().required(), + provider: Joi.string(), + region: Joi.string(), +})).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('POST /redis-enterprise/cloud/get-subscriptions', () => { + requirements('rte.cloud'); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should get subscriptions list', + data: { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, + }, + responseSchema, + checkFn: ({ body }) => { + expect(_.findIndex(body, { name: constants.TEST_CLOUD_SUBSCRIPTION_NAME })).to.gte(0); + }, + }, + { + name: 'Should throw Forbidden error when api key is incorrect', + data: { + apiKey: 'wrong-api-key', + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + + }, + { + name: 'Should throw Forbidden error when api secret key is incorrect', + data: { + apiKey: constants.TEST_CLOUD_API_KEY, + apiSecretKey: 'wrong-api-secret-key', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/commands/GET-commands.test.ts b/redisinsight/api/test/api/commands/GET-commands.test.ts new file mode 100644 index 0000000000..f3bab7c119 --- /dev/null +++ b/redisinsight/api/test/api/commands/GET-commands.test.ts @@ -0,0 +1,39 @@ +import { + expect, + describe, + it, + deps, + Joi, + fs, + validateApiCall, +} from '../deps'; +const { server, request } = deps; + +// endpoint to test +const endpoint = () => request(server).get('/commands'); + +const responseSchema = Joi.object().required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /commands', () => { + [ + { + name: 'Should return merged config', + statusCode: 200, + responseSchema, + checkFn: ({ body }) => { + expect(body['GET']).to.be.an('object'); + expect(body['FT.CREATE']).to.be.an('object'); + expect(body['JSON.GET']).to.be.an('object'); + }, + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/deps.ts b/redisinsight/api/test/api/deps.ts new file mode 100644 index 0000000000..345f004420 --- /dev/null +++ b/redisinsight/api/test/api/deps.ts @@ -0,0 +1,38 @@ +export * from '../helpers/test'; +import * as request from 'supertest'; +import * as chai from 'chai'; +import * as localDb from '../helpers/local-db'; +import { constants } from '../helpers/constants'; +import { getServer } from '../helpers/server'; +import { testEnv } from '../helpers/test'; +import * as redis from '../helpers/redis'; +import { initCloudDatabase } from '../helpers/cloud'; + +/** + * Initialize dependencies + */ +export async function depsInit () { + // create cloud subscription if needed + if(constants.TEST_CLOUD_RTE) { + await initCloudDatabase(); + } + // initializing backend server + deps.server = await getServer(); + + // initializing Redis Test Environment + deps.rte = await redis.initRTE(); + testEnv.rte = deps.rte.env; + + // initializing local database + await localDb.initLocalDb(deps.rte, deps.server); +} + +export const deps = { + localDb, + constants, + request, + expect: chai.expect, + server: null, + rte: null, + testEnv, +} diff --git a/redisinsight/api/test/api/enterprise/POST-redis-enterprise-cluster-get_dbs.test.ts b/redisinsight/api/test/api/enterprise/POST-redis-enterprise-cluster-get_dbs.test.ts new file mode 100644 index 0000000000..90cd2fb769 --- /dev/null +++ b/redisinsight/api/test/api/enterprise/POST-redis-enterprise-cluster-get_dbs.test.ts @@ -0,0 +1,69 @@ +import { describe, it, deps, validateApiCall, requirements } from '../deps'; +const { request, server, constants } = deps; + +const endpoint = () => request(server).post(`/redis-enterprise/cluster/get-dbs`); + +//todo: add response +//{ +// uid: 1, +// name: 'testdb', +// dnsName: 'redis-12010.cluster.local', +// address: '192.168.16.2', +// port: 12010, +// status: 'active', +// tls: false, +// modules: [], +// options: { +// enabledDataPersistence: false, +// persistencePolicy: 'none', +// enabledRedisFlash: false, +// enabledReplication: false, +// enabledBackup: false, +// enabledActiveActive: false, +// enabledClustering: true, +// isReplicaDestination: false, +// isReplicaSource: false +// } +// } + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + const { body } = await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('POST /redis-enterprise/cluster/get-dbs', () => { + requirements('rte.re'); + + [ + { + name: 'Should connect to a database', + data: { + host: constants.TEST_RE_HOST, + port: constants.TEST_RE_PORT, + password: constants.TEST_RE_PASS, + username: constants.TEST_RE_USER, + uids: [1], + }, + }, + { + name: 'Should return error if incorrect re credentials passed', + data: { + host: constants.TEST_RE_HOST, + port: constants.TEST_RE_PORT, + password: constants.TEST_RE_PASS + 1, + username: constants.TEST_RE_USER + 1, + uids: [1], + }, + // todo: why 403 when it should be 401??? + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/hash/DELETE-instance-id-hash-fields.test.ts b/redisinsight/api/test/api/hash/DELETE-instance-id-hash-fields.test.ts new file mode 100644 index 0000000000..adad482a33 --- /dev/null +++ b/redisinsight/api/test/api/hash/DELETE-instance-id-hash-fields.test.ts @@ -0,0 +1,223 @@ +import { + expect, + describe, + it, + before, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; +import * as Joi from 'joi'; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).delete(`/instance/${instanceId}/hash/fields`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + fields: Joi.array().items(Joi.any()).required(), // todo: investigate BE validation +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + fields: [constants.getRandomString()], +}; + +const responseSchema = Joi.object().keys({ + affected: Joi.number().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('DELETE /instance/:instanceId/hash/fields', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should ignore not existing field', + data: { + keyName: constants.TEST_HASH_KEY_2, + fields: [constants.getRandomString()], + }, + responseSchema, + responseBody: { + affected: 0, + }, + after: async () => { + const fields = await rte.client.hgetall(constants.TEST_HASH_KEY_2); + (new Array(3000).fill(0)).map((_, i) => { + expect(fields[`field_${i + 1}`]).to.eql(`value_${i + 1}`); + }); + } + }, + { + name: 'Should remove 1 field', + data: { + keyName: constants.TEST_HASH_KEY_2, + fields: ['field_3000'], + }, + responseSchema, + responseBody: { + affected: 1, + }, + after: async () => { + const fields = await rte.client.hgetall(constants.TEST_HASH_KEY_2); + (new Array(2999).fill(0)).map((_, i) => { + expect(fields[`field_${i + 1}`]).to.eql(`value_${i + 1}`); + }); + } + }, + { + name: 'Should remove multiple fields', + data: { + keyName: constants.TEST_HASH_KEY_2, + fields: ['field_2999', 'field_2998', 'field_1', 'field_2'], + }, + responseSchema, + responseBody: { + affected: 4, + }, + after: async () => { + const fields = await rte.client.hgetall(constants.TEST_HASH_KEY_2); + (new Array(2995).fill(0)).map((_, i) => { + expect(fields[`field_${i + 3}`]).to.eql(`value_${i + 3}`); + }); + } + }, + { + name: 'Should remove all fields and the key', + data: { + keyName: constants.TEST_HASH_KEY_2, + fields: [ + ...(new Array(2995).fill(0)).map((_, i) => `field_${i + 3}`) + ], + }, + responseSchema, + responseBody: { + affected: 2995, + }, + after: async () => { + expect(await rte.client.exists(constants.TEST_HASH_KEY_2)).to.eql(0); + } + }, + { + name: 'Should return BadRequest error if try to modify incorrect data type', + data: { + keyName: constants.TEST_STRING_KEY_1, + members: [constants.getRandomString()], + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + fields: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should not delete member', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [constants.getRandomString()], + }, + responseSchema, + responseBody: { + affected: 0, + }, + }, + { + name: 'Should throw error if no permissions for "hdel" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [constants.getRandomString()], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -hdel') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/hash/POST-instance-id-hash-get_fields.test.ts b/redisinsight/api/test/api/hash/POST-instance-id-hash-get_fields.test.ts new file mode 100644 index 0000000000..efa969c8a1 --- /dev/null +++ b/redisinsight/api/test/api/hash/POST-instance-id-hash-get_fields.test.ts @@ -0,0 +1,290 @@ +import { + expect, + describe, + it, + before, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; +import * as Joi from 'joi'; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/hash/get-fields`); + +// input data schema // todo: review BE for transform true -> 1 +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + cursor: Joi.number().integer().min(0).allow(true).required().messages({ + 'any.required': 'cursor should not be empty' + }), + count: Joi.number().integer().min(1).allow(true, null).messages({ + 'any.required': 'count should not be empty' + }), + match: Joi.string().allow(null), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + cursor: 0, + count: 1, + match: constants.getRandomString(), +}; + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), + total: Joi.number().integer().required(), + fields: Joi.array().items(Joi.object().keys({ + field: Joi.string().required(), + value: Joi.string().required(), + })), + nextCursor: Joi.number().integer().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/hash/get-fields', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should find by exact match', + data: { + keyName: constants.TEST_HASH_KEY_2, + cursor: 0, + count: 15, + match: 'field_9' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_HASH_KEY_2); + expect(body.total).to.eql(3000); + expect(body.fields.length).to.eql(1); + expect(body.fields[0].field).to.eql('field_9'); + expect(body.fields[0].value).to.eql('value_9'); + } + }, + { + name: 'Should not find any field', + data: { + keyName: constants.TEST_HASH_KEY_2, + cursor: 0, + count: 15, + match: 'field_9asd*' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_HASH_KEY_2); + expect(body.total).to.eql(3000); + expect(body.fields.length).to.eql(0); + } + }, + { + name: 'Should query 15 fields', + data: { + keyName: constants.TEST_HASH_KEY_2, + cursor: 0, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_HASH_KEY_2); + expect(body.total).to.eql(3000); + expect(body.fields.length).to.gte(15); + expect(body.fields.length).to.lt(3000); + } + }, + { + name: 'Should query by * in the end', + data: { + keyName: constants.TEST_HASH_KEY_2, + cursor: 0, + count: 15, + match: 'field_219*' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_HASH_KEY_2); + expect(body.total).to.eql(3000); + expect(body.fields.length).to.eq(11); + } + }, + { + name: 'Should query by * in the beginning', + data: { + keyName: constants.TEST_HASH_KEY_2, + cursor: 0, + count: 15, + match: '*eld_9' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_HASH_KEY_2); + expect(body.total).to.eql(3000); + expect(body.fields.length).to.eq(1); + expect(body.fields[0].field).to.eql('field_9'); + expect(body.fields[0].value).to.eql('value_9'); + } + }, + { + name: 'Should query by * in the middle', + data: { + keyName: constants.TEST_HASH_KEY_2, + cursor: 0, + count: 15, + match: 'f*eld_9' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_HASH_KEY_2); + expect(body.total).to.eql(3000); + expect(body.fields.length).to.eq(1); + expect(body.fields[0].field).to.eql('field_9'); + expect(body.fields[0].value).to.eql('value_9'); + } + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + cursor: 0, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + cursor: 0, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + + describe('Search in huge number of fields', () => { + requirements('rte.onPremise'); + // Number of fields to generate. Could be 10M or even more but consume much more time + // We decide to generate 500K which should take ~10s + const NUMBER_OF_FIELDS = 500 * 1000; + before(async () => await rte.data.generateHugeNumberOfFieldsForHashKey(NUMBER_OF_FIELDS, true)); + + [ + { + name: 'Should find exact one key', + data: { + keyName: constants.TEST_HASH_KEY_1, + cursor: 0, + count: 15, + match: 'f_48900' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_HASH_KEY_1); + expect(body.total).to.eql(NUMBER_OF_FIELDS); + expect(body.fields.length).to.eq(1); + expect(body.fields[0].field).to.eql('f_48900'); + expect(body.fields[0].value).to.eql('v'); + } + }, + ].map(mainCheckFn); + }); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should not delete member', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + cursor: 0, + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "hlen" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + cursor: 0, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -hlen') + }, + { + name: 'Should throw error if no permissions for "hget" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + cursor: 0, + match: 'asd', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -hget') + }, + { + name: 'Should throw error if no permissions for "hscan" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + cursor: 0, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -hscan') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/hash/POST-instance-id-hash.test.ts b/redisinsight/api/test/api/hash/POST-instance-id-hash.test.ts new file mode 100644 index 0000000000..e814964746 --- /dev/null +++ b/redisinsight/api/test/api/hash/POST-instance-id-hash.test.ts @@ -0,0 +1,212 @@ +import { + expect, + describe, + it, + before, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; +import * as Joi from 'joi'; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/hash`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + fields: Joi.array().items(Joi.object().keys({ + field: Joi.string().allow('').label('.field'), + value: Joi.string().allow('').label('.value'), + })).required().messages({ + 'array.sparse': 'fields must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), + expire: Joi.number().integer().allow(null).min(1).max(2147483647), +}).strict(); + +const validInputData = { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.TEST_HASH_FIELD_1_NAME, + value: constants.TEST_HASH_FIELD_1_VALUE, + }], + expire: constants.TEST_HASH_EXPIRE_1, +}; + + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(0); + } + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(1); + expect(await rte.client.hgetall(testCase.data.keyName)).to.eql({ + [testCase.data.fields[0].field]: testCase.data.fields[0].value, + }); + expect(await rte.client.ttl(testCase.data.keyName)).to.eql(testCase.data.expire || -1); + } + } + }); +}; + +describe('POST /instance/:instanceId/hash', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should create item with empty value', + data: { + keyName: constants.getRandomString(), + fields: [{ + field: '', + value: '', + }], + }, + statusCode: 201, + }, + { + name: 'Should create item with key ttl', + data: { + keyName: constants.getRandomString(), + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + expire: constants.TEST_HASH_EXPIRE_1, + }, + statusCode: 201, + }, + { + name: 'Should create regular item', + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.TEST_HASH_FIELD_1_NAME, + value: constants.TEST_HASH_FIELD_1_VALUE, + }], + }, + statusCode: 201, + }, + { + name: 'Should return conflict error if key already exists', + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 409, + responseBody: { + statusCode: 409, + error: 'Conflict', + message: 'This key name is already in use.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.hgetall(constants.TEST_HASH_KEY_1)).to.deep.eql({ + [constants.TEST_HASH_FIELD_1_NAME]: constants.TEST_HASH_FIELD_1_VALUE, + }) + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 201, + }, + { + name: 'Should throw error if no permissions for "hset" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -hset') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/hash/PUT-instance-id-hash.test.ts b/redisinsight/api/test/api/hash/PUT-instance-id-hash.test.ts new file mode 100644 index 0000000000..48a1eba613 --- /dev/null +++ b/redisinsight/api/test/api/hash/PUT-instance-id-hash.test.ts @@ -0,0 +1,180 @@ +import { + expect, + describe, + it, + before, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; +import * as Joi from 'joi'; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).put(`/instance/${instanceId}/hash`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + fields: Joi.array().items(Joi.object().keys({ + field: Joi.string().allow('').label('.field'), + value: Joi.string().allow('').label('.value'), + })).required().messages({ + 'array.sparse': 'fields must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + fields: [{ + field: constants.TEST_HASH_FIELD_1_NAME, + value: constants.TEST_HASH_FIELD_1_VALUE, + }], +}; + + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PUT /instance/:instanceId/hash', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should add new field and edit existing value', + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.TEST_HASH_FIELD_1_NAME, + value: '', + }, { + field: 'new_field', + value: 'new_value', + }], + }, + statusCode: 200, + after: async () => { + expect(await rte.client.hgetall(constants.TEST_HASH_KEY_1)).to.eql({ + [constants.TEST_HASH_FIELD_1_NAME]: '', + [constants.TEST_HASH_FIELD_2_NAME]: constants.TEST_HASH_FIELD_2_VALUE, + ['new_field']: 'new_value', + }); + } + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "hset" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -hset') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_HASH_KEY_1, + fields: [{ + field: constants.getRandomString(), + value: constants.getRandomString(), + }], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/info/GET-info-cli-blocking-commands.test.ts b/redisinsight/api/test/api/info/GET-info-cli-blocking-commands.test.ts new file mode 100644 index 0000000000..2b75f34515 --- /dev/null +++ b/redisinsight/api/test/api/info/GET-info-cli-blocking-commands.test.ts @@ -0,0 +1,42 @@ +import { + describe, + it, + deps, + Joi, + validateApiCall, +} from '../deps'; +const { server, request } = deps; + +// endpoint to test +const endpoint = () => request(server).get('/info/cli-blocking-commands'); + +const responseSchema = Joi.array().items(Joi.string()) + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /info/cli-blocking-commands', () => { + [ + { + name: 'Should return array with blocking Redis commands', + statusCode: 200, + responseSchema, + responseBody: [ + 'blpop', + 'brpop', + 'blmove', + 'brpoplpush', + 'bzpopmin', + 'bzpopmax', + 'xread', + 'xreadgroup' + ], + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/info/GET-info-cli-unsupported-commands.test.ts b/redisinsight/api/test/api/info/GET-info-cli-unsupported-commands.test.ts new file mode 100644 index 0000000000..bbdd4d97bf --- /dev/null +++ b/redisinsight/api/test/api/info/GET-info-cli-unsupported-commands.test.ts @@ -0,0 +1,33 @@ +import { + describe, + it, + deps, + Joi, + validateApiCall, +} from '../deps'; +const { server, request } = deps; + +// endpoint to test +const endpoint = () => request(server).get('/info/cli-unsupported-commands'); + +const responseSchema = Joi.array().items(Joi.string()) + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /info/cli-unsupported-commands', () => { + [ + { + name: 'Should return array with unsupported commands for CLI tool', + statusCode: 200, + responseSchema, + responseBody: ['monitor', 'subscribe', 'psubscribe', 'sync', 'psync', 'script debug'], + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/info/GET-info.test.ts b/redisinsight/api/test/api/info/GET-info.test.ts new file mode 100644 index 0000000000..b45f26f762 --- /dev/null +++ b/redisinsight/api/test/api/info/GET-info.test.ts @@ -0,0 +1,43 @@ +import { + expect, + describe, + it, + deps, + Joi, + validateApiCall, +} from '../deps'; +const { server, request } = deps; + +// endpoint to test +const endpoint = () => request(server).get('/info'); + +const responseSchema = Joi.object().keys({ + id: Joi.string().required(), + createDateTime: Joi.date().required(), + appVersion: Joi.string().required(), + osPlatform: Joi.string().required(), + buildType: Joi.string().valid('ELECTRON', 'DOCKER_ON_PREMISE').required(), + encryptionStrategies: Joi.array().items(Joi.string()), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /info', () => { + [ + { + name: 'Should return server info', + statusCode: 200, + responseSchema, + checkFn: ({ body }) => { + expect(body.osPlatform).to.eql(process.platform); + } + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/instance/DELETE-instance-id.test.ts b/redisinsight/api/test/api/instance/DELETE-instance-id.test.ts new file mode 100644 index 0000000000..e0ef1c51c3 --- /dev/null +++ b/redisinsight/api/test/api/instance/DELETE-instance-id.test.ts @@ -0,0 +1,63 @@ +import { + expect, + describe, + it, + before, + deps, + validateApiCall, +} from '../deps'; + +const { request, server, localDb, constants } = deps; + +const endpoint = id => request(server).delete(`/instance/${id}`); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('DELETE /instance/:id', () => { + before(async () => await localDb.createDatabaseInstances()); + + describe('Common', () => { + [ + { + name: 'Should remove single database', + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + before: async () => { + expect(await localDb.getInstanceByName(constants.TEST_INSTANCE_NAME_2)).to.not.eql(undefined) + }, + after: async () => { + expect(await localDb.getInstanceByName(constants.TEST_INSTANCE_NAME_2)).to.eql(undefined) + }, + }, + { + name: 'Should return Not Found Error', + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + before: async () => { + expect(await localDb.getInstanceByName(constants.TEST_INSTANCE_NAME_2)).to.eql(undefined) + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/instance/DELETE-instance.test.ts b/redisinsight/api/test/api/instance/DELETE-instance.test.ts new file mode 100644 index 0000000000..d6c734d101 --- /dev/null +++ b/redisinsight/api/test/api/instance/DELETE-instance.test.ts @@ -0,0 +1,88 @@ +import { + Joi, + expect, + describe, + it, + before, + deps, + validateApiCall, + generateInvalidDataTestCases, + validateInvalidDataTestCase, +} from '../deps'; + +const { request, server, localDb, constants } = deps; + +const endpoint = () => request(server).delete(`/instance`); + +// input data schema +const dataSchema = Joi.object({ + ids: Joi.array().items(Joi.any()).required(), +}).strict(); + +const validInputData = { + ids: [constants.getRandomString()], +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('DELETE /instance', () => { + before(async () => await localDb.createDatabaseInstances()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should remove multiple databases by ids', + data: { + ids: [constants.TEST_INSTANCE_ID_2, constants.TEST_INSTANCE_ID_3] + }, + responseBody: { + affected: 2, + }, + before: async () => { + expect(await localDb.getInstanceByName(constants.TEST_INSTANCE_NAME_2)).to.not.eql(undefined) + expect(await localDb.getInstanceByName(constants.TEST_INSTANCE_NAME_3)).to.not.eql(undefined) + }, + after: async () => { + expect(await localDb.getInstanceByName(constants.TEST_INSTANCE_NAME_2)).to.eql(undefined) + expect(await localDb.getInstanceByName(constants.TEST_INSTANCE_NAME_3)).to.eql(undefined) + }, + }, + { + name: 'Should return affected 0 since no databases found', + data: { + ids: [constants.TEST_INSTANCE_ID_2, constants.TEST_INSTANCE_ID_3] + }, + responseBody: { + affected: 0, + }, + before: async () => { + expect(await localDb.getInstanceByName(constants.TEST_INSTANCE_NAME_2)).to.eql(undefined) + expect(await localDb.getInstanceByName(constants.TEST_INSTANCE_NAME_3)).to.eql(undefined) + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/instance/GET-instance-id-connect.test.ts b/redisinsight/api/test/api/instance/GET-instance-id-connect.test.ts new file mode 100644 index 0000000000..432180cfba --- /dev/null +++ b/redisinsight/api/test/api/instance/GET-instance-id-connect.test.ts @@ -0,0 +1,45 @@ +import { describe, it, deps, validateApiCall, before } from '../deps'; +const { localDb, request, server, constants } = deps; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).get(`/instance/${instanceId}/connect`); + + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /instance/:instanceId/connect', () => { + before(async () => await localDb.createDatabaseInstances()); + + [ + { + name: 'Should connect to a database', + statusCode: 200, + }, + { + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + name: 'Should not connect to a database due to misconfiguration', + statusCode: 503, + responseBody: { + statusCode: 503, + error: 'Service Unavailable' + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/instance/GET-instance-id-info.test.ts b/redisinsight/api/test/api/instance/GET-instance-id-info.test.ts new file mode 100644 index 0000000000..63fa9dc47d --- /dev/null +++ b/redisinsight/api/test/api/instance/GET-instance-id-info.test.ts @@ -0,0 +1,71 @@ +import { describe, it, deps, validateApiCall, before, expect } from '../deps'; +import { Joi } from '../../helpers/test'; +const { localDb, request, server, constants, rte } = deps; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).get(`/instance/${instanceId}/info`); + +const responseSchema = Joi.object().keys({ + version: Joi.string().required(), + databases: Joi.number().integer(), + role: Joi.string(), + totalKeys: Joi.number().integer().required(), + usedMemory: Joi.number().integer().required(), + connectedClients: Joi.number().integer(), + uptimeInSeconds: Joi.number().integer(), + hitRatio: Joi.number(), + server: Joi.object(), + nodes: Joi.array().items(Joi.object().keys({ + version: Joi.string().required(), + databases: Joi.number().integer().required(), + role: Joi.string().required(), + totalKeys: Joi.number().integer().required(), + usedMemory: Joi.number().integer().required(), + connectedClients: Joi.number().integer().required(), + uptimeInSeconds: Joi.number().integer().required(), + hitRatio: Joi.number().required(), + server: Joi.object().required(), + })), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /instance/:instanceId/info', () => { + before(localDb.createDatabaseInstances); + + [ + { + name: 'Should connect to a database', + responseSchema, + checkFn: ({body}) => { + expect(body.version).to.eql(rte.env.version); + } + }, + { + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + name: 'Should not connect to a database due to misconfiguration', + statusCode: 503, + responseBody: { + statusCode: 503, + error: 'Service Unavailable' + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/instance/GET-instance-id-overview.test.ts b/redisinsight/api/test/api/instance/GET-instance-id-overview.test.ts new file mode 100644 index 0000000000..a7bcf9b8b9 --- /dev/null +++ b/redisinsight/api/test/api/instance/GET-instance-id-overview.test.ts @@ -0,0 +1,59 @@ +import { describe, it, deps, validateApiCall, before, expect } from '../deps'; +import { Joi } from '../../helpers/test'; +const { localDb, request, server, constants, rte } = deps; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).get(`/instance/${instanceId}/overview`); + +const responseSchema = Joi.object().keys({ + version: Joi.string().required(), + totalKeys: Joi.number().integer().allow(null).required(), + usedMemory: Joi.number().integer().allow(null).required(), + connectedClients: Joi.number().allow(null).required(), + opsPerSecond: Joi.number().allow(null).required(), + networkInKbps: Joi.number().allow(null).required(), + networkOutKbps: Joi.number().integer().allow(null).required(), + cpuUsagePercentage: Joi.number().allow(null).required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /instance/:instanceId/overview', () => { + before(localDb.createDatabaseInstances); + + [ + { + name: 'Should get database overview', + responseSchema, + checkFn: ({body}) => { + expect(body.version).to.eql(rte.env.version); + } + }, + { + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + name: 'Should not connect to a database due to misconfiguration', + statusCode: 503, + responseBody: { + statusCode: 503, + error: 'Service Unavailable' + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/instance/GET-instance-id-plugin-commands.test.ts b/redisinsight/api/test/api/instance/GET-instance-id-plugin-commands.test.ts new file mode 100644 index 0000000000..7afd2cddb1 --- /dev/null +++ b/redisinsight/api/test/api/instance/GET-instance-id-plugin-commands.test.ts @@ -0,0 +1,52 @@ +import { describe, it, deps, validateApiCall, before, expect } from '../deps'; +import { Joi } from '../../helpers/test'; +const { localDb, request, server, constants, rte } = deps; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).get(`/instance/${instanceId}/plugin-commands`); + +const responseSchema = Joi.array().items(Joi.string()).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /instance/:instanceId/plugin-commands', () => { + before(localDb.createDatabaseInstances); + + [ + { + name: 'Should get plugin commands whitelist', + responseSchema, + checkFn: ({body}) => { + expect(body).to.include('get'); + expect(body).to.not.include('role'); + expect(body).to.not.include('xread'); + } + }, + { + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + name: 'Should not connect to a database due to misconfiguration', + statusCode: 503, + responseBody: { + statusCode: 503, + error: 'Service Unavailable' + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/instance/GET-instance.test.ts b/redisinsight/api/test/api/instance/GET-instance.test.ts new file mode 100644 index 0000000000..aa282479e7 --- /dev/null +++ b/redisinsight/api/test/api/instance/GET-instance.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, deps, validateApiCall, before, _ } from '../deps'; +import { Joi } from '../../helpers/test'; +const { localDb, request, server, constants, rte } = deps; + +const endpoint = () => + request(server).get(`/instance`); + +const responseSchema = Joi.array().items(Joi.object().keys({ + id: Joi.string().required(), + host: Joi.string().required(), + port: Joi.number().integer().required(), + db: Joi.number().integer().allow(null), + name: Joi.string().required(), + username: Joi.string().allow(null).required(), + password: Joi.string().allow(null).required(), + connectionType: Joi.string().valid('STANDALONE', 'SENTINEL', 'CLUSTER').required(), + nameFromProvider: Joi.string().allow(null).required(), + lastConnection: Joi.date().allow(null).required(), + provider: Joi.string().required(), + tls: Joi.object().keys({ + verifyServerCert: Joi.boolean().required(), + caCertId: Joi.string(), + clientCertPairId: Joi.string(), + }), + sentinelMaster: Joi.object().keys({ + name: Joi.string().required(), + username: Joi.string().allow(null).required(), + password: Joi.string().allow(null).required(), + }), + endpoints: Joi.array().items(Joi.object().keys({ + host: Joi.string().required(), + port: Joi.number().integer().required(), + })), + modules: Joi.array().items(Joi.object().keys({ + name: Joi.string().required(), + version: Joi.number().integer().required(), + semanticVersion: Joi.string(), + })).min(0).required(), +})).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /instance', () => { + before(async () => { + await localDb.createDatabaseInstances(); + // initializing modules list when ran as standalone test + await request(server).get(`/instance/${constants.TEST_INSTANCE_ID}/connect`); + }); + + [ + { + name: 'Should get instances list', + responseSchema, + checkFn: ({ body }) => { + const instance = _.find(body, { id: constants.TEST_INSTANCE_ID }) + _.map(rte.env.modules, (module, name) => { + expect(_.find(instance.modules, module => module.name.toLowerCase() === name).version) + .to.eql(module.version); + }) + } + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/instance/PATCH-instance-id-name.test.ts b/redisinsight/api/test/api/instance/PATCH-instance-id-name.test.ts new file mode 100644 index 0000000000..3d28df7930 --- /dev/null +++ b/redisinsight/api/test/api/instance/PATCH-instance-id-name.test.ts @@ -0,0 +1,95 @@ +import { + expect, + describe, + it, + before, + deps, + validateApiCall, + generateInvalidDataTestCases, + validateInvalidDataTestCase, +} from '../deps'; +import { Joi } from '../../helpers/test'; + +const { request, server, localDb, constants } = deps; + +const endpoint = (id = constants.TEST_INSTANCE_ID_2) => request(server).patch(`/instance/${id}/name`); + +// input data schema +const dataSchema = Joi.object({ + newName: Joi.string().max(500).required(), +}).messages({ + 'any.required': '{#label} should not be empty', +}).strict(); + +const responseSchema = Joi.object().keys({ + oldName: Joi.string().required(), + newName: Joi.string().required(), +}).required(); + +const validInputData = { + newName: 'new name', +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PATCH /instance/:id/name', () => { + before(async () => await localDb.createDatabaseInstances()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should change name for existing database', + data: validInputData, + responseSchema, + before: async () => { + const instance = await localDb.getInstanceById(constants.TEST_INSTANCE_ID_2); + + expect(instance.name).to.eql(constants.TEST_INSTANCE_NAME_2) + }, + responseBody: { + oldName: constants.TEST_INSTANCE_NAME_2, + newName: validInputData.newName, + }, + after: async () => { + const instance = await localDb.getInstanceById(constants.TEST_INSTANCE_ID_2); + + expect(instance.name).to.eql('new name'); + }, + }, + { + name: 'Should return Not Found Error', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: validInputData, + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/instance/POST-instance-sentinel_masters.test.ts b/redisinsight/api/test/api/instance/POST-instance-sentinel_masters.test.ts new file mode 100644 index 0000000000..de1e5ab3d3 --- /dev/null +++ b/redisinsight/api/test/api/instance/POST-instance-sentinel_masters.test.ts @@ -0,0 +1,110 @@ +import { Joi, expect, describe, it, deps, requirements, validateApiCall } from '../deps'; +const { rte, request, server, constants } = deps; + +const endpoint = () => request(server).post('/instance/sentinel-masters'); + +const responseSchema = Joi.array().items(Joi.object().keys({ + id: Joi.string().required(), + name: Joi.string().required(), + status: Joi.string().required(), + message: Joi.string().required(), +})); + +describe('POST /instance/sentinel-masters', () => { + requirements('rte.type=SENTINEL'); + + // todo: add validation tests + describe('Validation', function () {}); + // todo: cover connection error for incorrect host + port [describe('common')] + describe('Common', () => { + it('Create sentinel database', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: constants.TEST_REDIS_USER, + password: constants.TEST_REDIS_PASSWORD, + masters: [{ + alias: dbName, + name: constants.TEST_SENTINEL_MASTER_GROUP, + username: constants.TEST_SENTINEL_MASTER_USER, + password: constants.TEST_SENTINEL_MASTER_PASS, + }], + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.length).to.eql(1); + expect(body[0].name).to.eql(constants.TEST_SENTINEL_MASTER_GROUP); + expect(body[0].status).to.eql('success'); + expect(body[0].message).to.eql('Added'); + }, + }); + }); + it('Create sentinel database with particular db index', async () => { + let addedId; + const dbName = constants.getRandomString(); + const cliUuid = constants.getRandomString(); + const browserKeyName = constants.getRandomString(); + const cliKeyName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: constants.TEST_REDIS_USER, + password: constants.TEST_REDIS_PASSWORD, + masters: [{ + db: constants.TEST_REDIS_DB_INDEX, + alias: dbName, + name: constants.TEST_SENTINEL_MASTER_GROUP, + username: constants.TEST_SENTINEL_MASTER_USER, + password: constants.TEST_SENTINEL_MASTER_PASS, + }], + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.length).to.eql(1); + addedId = body[0].id; + expect(body[0].name).to.eql(constants.TEST_SENTINEL_MASTER_GROUP); + expect(body[0].status).to.eql('success'); + expect(body[0].message).to.eql('Added'); + }, + }); + + // Create string using Browser API to particular db index + await validateApiCall({ + endpoint: () => request(server).post(`/instance/${addedId}/string`), + statusCode: 201, + data: { + keyName: browserKeyName, + value: 'somevalue' + }, + }); + + // Create string using CLI API to 0 db index + await validateApiCall({ + endpoint: () => request(server).post(`/instance/${addedId}/cli/${cliUuid}/send-command`), + statusCode: 200, + data: { + command: `set ${cliKeyName} somevalue`, + }, + }); + + // check data created by db index + await rte.data.executeCommand('select', `${constants.TEST_REDIS_DB_INDEX}`); + expect(await rte.data.executeCommand('exists', cliKeyName)).to.eql(0) + expect(await rte.data.executeCommand('exists', browserKeyName)).to.eql(1) + + // check data created by db index + await rte.data.executeCommand('select', '0'); + expect(await rte.data.executeCommand('exists', cliKeyName)).to.eql(1) + expect(await rte.data.executeCommand('exists', browserKeyName)).to.eql(0) + }); + }); +}); diff --git a/redisinsight/api/test/api/instance/POST-instance.test.ts b/redisinsight/api/test/api/instance/POST-instance.test.ts new file mode 100644 index 0000000000..83a4478e29 --- /dev/null +++ b/redisinsight/api/test/api/instance/POST-instance.test.ts @@ -0,0 +1,549 @@ +import { Joi, expect, describe, it, before, deps, requirements, validateApiCall } from '../deps'; +const { rte, request, server, localDb, constants } = deps; + +const endpoint = () => request(server).post('/instance'); + +const responseSchema = Joi.object().keys({ + id: Joi.string().required(), + name: Joi.string().required(), + host: Joi.string().required(), + port: Joi.number().integer().required(), + db: Joi.number().integer().allow(null), + connectionType: Joi.string().valid('STANDALONE', 'CLUSTER', 'SENTINEL').required(), + username: Joi.string().allow(null).required(), + password: Joi.string().allow(null).required(), + nameFromProvider: Joi.string().allow(null).required(), + lastConnection: Joi.date().allow(null).required(), + provider: Joi.string().valid('LOCALHOST', 'UNKNOWN', 'RE_CLOUD', 'RE_CLUSTER').required(), + tls: Joi.object().keys({ + verifyServerCert: Joi.boolean().required(), + caCertId: Joi.string(), + clientCertPairId: Joi.string(), + }), + endpoints: Joi.array().items({ + host: Joi.string().required(), + port: Joi.number().integer().required(), + }), + modules: Joi.array().items({ + name: Joi.string().required(), + version: Joi.number().integer(), + semanticVersion: Joi.string(), + }), +}).required(); + +describe('POST /instance', () => { + // todo: add validation tests + describe('Validation', function () {}); + // todo: cover connection error for incorrect host + port [describe('common')] + describe('STANDALONE', () => { + requirements('rte.type=STANDALONE'); + describe('Create standalone instance without pass', function () { + requirements('!rte.tls', '!rte.pass'); + it('Create standalone without pass', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: null, + password: null, + connectionType: constants.STANDALONE, + }, + }); + }); + describe('Enterprise', () => { + requirements('rte.re'); + it('Should throw an error if db index specified', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 400, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + db: constants.TEST_REDIS_DB_INDEX + }, + }); + }); + }); + describe('Oss', () => { + requirements('!rte.re'); + it('Create standalone with particular db index', async () => { + let addedId; + const dbName = constants.getRandomString(); + const cliUuid = constants.getRandomString(); + const browserKeyName = constants.getRandomString(); + const cliKeyName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + db: constants.TEST_REDIS_DB_INDEX, + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + db: constants.TEST_REDIS_DB_INDEX, + username: null, + password: null, + connectionType: constants.STANDALONE, + }, + checkFn: ({ body }) => { + addedId = body.id; + } + }); + + // Create string using Browser API to particular db index + await validateApiCall({ + endpoint: () => request(server).post(`/instance/${addedId}/string`), + statusCode: 201, + data: { + keyName: browserKeyName, + value: 'somevalue' + }, + }); + + // Create string using CLI API to 0 db index + await validateApiCall({ + endpoint: () => request(server).post(`/instance/${addedId}/cli/${cliUuid}/send-command`), + statusCode: 200, + data: { + command: `set ${cliKeyName} somevalue`, + }, + }); + + // check data created by db index + await rte.data.executeCommand('select', `${constants.TEST_REDIS_DB_INDEX}`); + expect(await rte.data.executeCommand('exists', cliKeyName)).to.eql(0) + expect(await rte.data.executeCommand('exists', browserKeyName)).to.eql(1) + + // check data created by db index + await rte.data.executeCommand('select', '0'); + expect(await rte.data.executeCommand('exists', cliKeyName)).to.eql(1) + expect(await rte.data.executeCommand('exists', browserKeyName)).to.eql(0) + }); + }); + }); + describe('Create standalone instance with password', function () { + requirements('!rte.tls', 'rte.pass'); + it('Create standalone with password', async () => { + const dbName = constants.getRandomString(); + + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + password: constants.TEST_REDIS_PASSWORD, + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: null, + password: constants.TEST_REDIS_PASSWORD, + connectionType: constants.STANDALONE, + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + // todo: cover connection error for incorrect username/password + }); + describe('Create standalone instance tls', function () { + requirements('rte.tls', '!rte.tlsAuth'); + it('Create standalone instance using tls without CA verify', async () => { + const dbName = constants.getRandomString(); + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: { + verifyServerCert: false, + } + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: { + verifyServerCert: false, + } + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + it('Create standalone instance using tls and verify and create CA certificate (new)', async () => { + const dbName = constants.getRandomString(); + const newCaName = constants.getRandomString(); + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: { + verifyServerCert: true, + newCaCert: { + name: newCaName, + cert: constants.TEST_REDIS_TLS_CA, + } + } + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: { + verifyServerCert: true, + }, + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + it('Should throw an error without CA cert when cert validation enabled', async () => { + const dbName = constants.getRandomString(); + + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + + await validateApiCall({ + endpoint, + statusCode: 400, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: { + verifyServerCert: true, + } + }, + responseBody: { + statusCode: 400, + // todo: verify error handling because right now messages are different + // message: '???', + error: 'Bad Request' + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + }); + it('Should throw an error with invalid CA cert', async () => { + const dbName = constants.getRandomString(); + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + + await validateApiCall({ + endpoint, + statusCode: 400, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: { + verifyServerCert: true, + newCaCert: { + name: 'aaaaa', + cert: 'invalid' + } + } + }, + responseBody: { + statusCode: 400, + // todo: verify error handling because right now messages are different + // message: '???', + error: 'Bad Request' + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + }); + }); + describe('Create standalone instance tls with certificate auth', function () { + requirements('rte.tls', 'rte.tlsAuth'); + + let existingCACertId, existingClientCertId, existingClientCertName; + before(async () => { + // await localDb + }); + it('Create standalone instance and verify users certs (new certificates)', async () => { + const dbName = constants.getRandomString(); + const newCaName = constants.getRandomString(); + const newClientCertName = existingClientCertName = constants.getRandomString(); + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + + const { body } = await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: { + verifyServerCert: true, + newCaCert: { + name: newCaName, + cert: constants.TEST_REDIS_TLS_CA, + }, + newClientCertPair: { + name: newClientCertName, + cert: constants.TEST_USER_TLS_CERT, + key: constants.TEST_USER_TLS_KEY, + } + } + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: { + verifyServerCert: true, + } + }, + }); + + // remember certificates ids + existingCACertId = body.caCertid; + existingClientCertId = body.clientCertPairId; + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + // todo: investigate/fix an error (self signed certificate in the certificates chain) + xit('Should create standalone instance with existing certificates', async () => { + const dbName = constants.getRandomString(); + + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: { + verifyServerCert: true, + caCertId: existingCACertId, + clientCertPairId: existingClientCertId, + }, + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: { + verifyServerCert: true, + caCertId: existingCACertId, + clientCertPairId: existingClientCertId, + } + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + it('Should throw an error if try to create client certificate with existing name', async () => { + const dbName = constants.getRandomString(); + const newCaName = constants.getRandomString(); + + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + + await validateApiCall({ + endpoint, + statusCode: 400, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: { + verifyServerCert: true, + newCaCert: { + name: newCaName, + cert: constants.TEST_REDIS_TLS_CA, + }, + newClientCertPair: { + name: existingClientCertName, + cert: constants.TEST_USER_TLS_CERT, + key: constants.TEST_USER_TLS_KEY, + } + } + }, + responseBody: { + error: 'Bad Request', + message: 'This client certificate name is already in use.', + statusCode: 400, + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.eql(undefined); + }); + }); + }); + describe('CLUSTER', () => { + requirements('rte.type=CLUSTER'); + describe('Create cluster instance without pass', function () { + requirements('!rte.tls', '!rte.pass'); + it('Create instance without pass', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }, + responseSchema, + responseBody: { + name: dbName, + port: constants.TEST_REDIS_PORT, + connectionType: constants.CLUSTER, + endpoints: rte.env.nodes, + }, + }); + }); + it('Should throw an error if db index specified', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 400, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + db: constants.TEST_REDIS_DB_INDEX + }, + }); + }); + }); + describe('TLS CA', function () { + requirements('rte.tls', '!rte.tlsAuth'); + it('Should create instance without CA tls', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: { + verifyServerCert: false, + } + }, + responseSchema, + responseBody: { + name: dbName, + port: constants.TEST_REDIS_PORT, + connectionType: constants.CLUSTER, + endpoints: rte.env.nodes, + tls: { + verifyServerCert: false, + } + }, + }); + }); + it('Should create instance tls and create new CA cert', async () => { + const dbName = constants.getRandomString(); + + const { body } = await validateApiCall({ + endpoint, + statusCode: 201, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: { + verifyServerCert: true, + newCaCert: { + name: constants.getRandomString(), + cert: constants.TEST_REDIS_TLS_CA, + }, + }, + }, + responseSchema, + responseBody: { + name: dbName, + port: constants.TEST_REDIS_PORT, + connectionType: constants.CLUSTER, + endpoints: rte.env.nodes, + tls: { + verifyServerCert: true, + }, + }, + }); + }); + // todo: Should throw an error without CA cert when cert validation enabled + // todo: Should throw an error with invalid CA cert + }); + }); + describe('SENTINEL', () => { + requirements('rte.type=SENTINEL'); + it('Should always throw an Invalid Data error for sentinel', async () => { + await validateApiCall({ + endpoint, + data: { + name: constants.getRandomString(), + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + password: constants.TEST_REDIS_PASSWORD, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'SENTINEL_PARAMS_REQUIRED', + message: 'Sentinel master name must be specified.' + }, + }); + }); + }); +}); diff --git a/redisinsight/api/test/api/instance/PUT-instance-id.test.ts b/redisinsight/api/test/api/instance/PUT-instance-id.test.ts new file mode 100644 index 0000000000..672f18d8e3 --- /dev/null +++ b/redisinsight/api/test/api/instance/PUT-instance-id.test.ts @@ -0,0 +1,116 @@ +import { + expect, + describe, + it, + before, + deps, + validateApiCall, generateInvalidDataTestCases, validateInvalidDataTestCase, requirements +} from '../deps'; +import { Joi } from '../../helpers/test'; + +const { request, server, localDb, constants } = deps; + +const endpoint = (id = constants.TEST_INSTANCE_ID_2) => request(server).put(`/instance/${id}`); + +// input data schema +const dataSchema = Joi.object({ + name: Joi.string().required(), + host: Joi.string().required(), + port: Joi.number().integer().allow(true).required(), +}).messages({ + 'any.required': '{#label} should not be empty', +}).strict(); + +const validInputData = { + name: constants.getRandomString(), + host: constants.getRandomString(), + port: 111, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PUT /instance/:id', () => { + requirements('rte.type=STANDALONE', '!rte.pass', '!rte.tls'); + before(async () => await localDb.createDatabaseInstances()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should change data for existing database', + data: { + name: 'new name', + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }, + before: async () => { + expect(await localDb.getInstanceByName('new name')).to.eql(undefined) + }, + after: async () => { + const newDb = await localDb.getInstanceByName('new name'); + expect(newDb.name).to.eql('new name'); + expect(newDb.host).to.eql(constants.TEST_REDIS_HOST); + expect(newDb.port).to.eql(constants.TEST_REDIS_PORT); + }, + }, + { + name: 'Should return 503 error if incorrect connection data provided', + data: { + name: 'new name', + host: constants.TEST_REDIS_HOST, + port: 1111, + }, + statusCode: 503, + responseBody: { + statusCode: 503, + message: `Could not connect to ${constants.TEST_REDIS_HOST}:1111, please check the connection details.`, + error: 'Service Unavailable' + }, + after: async () => { + // check that instance wasn't changed + const newDb = await localDb.getInstanceByName('new name'); + expect(newDb.name).to.eql('new name'); + expect(newDb.host).to.eql(constants.TEST_REDIS_HOST); + expect(newDb.port).to.eql(constants.TEST_REDIS_PORT); + }, + }, + { + name: 'Should return Not Found Error', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + name: 'new name', + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/keys/DELETE-instance-id-keys.test.ts b/redisinsight/api/test/api/keys/DELETE-instance-id-keys.test.ts new file mode 100644 index 0000000000..2227322a94 --- /dev/null +++ b/redisinsight/api/test/api/keys/DELETE-instance-id-keys.test.ts @@ -0,0 +1,204 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).delete(`/instance/${instanceId}/keys`); + +// input data schema +const dataSchema = Joi.object({ + keyNames: Joi.array().items(Joi.string().allow('')).required().messages({ + 'string.base': 'each value in keyNames must be a string' + }), +}).strict(); + +const validInputData = { + keyNames: [constants.TEST_LIST_KEY_1], +}; + +const responseSchema = Joi.object().keys({ + affected: Joi.number().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + if (testCase.before) { + await testCase.before(); + } else if (testCase.statusCode < 300) { + testCase.data.keyNames.map(async (keyName) => { + expect(await rte.client.exists(keyName)).to.eql(1); + }); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + if (testCase.after) { + await testCase.after(); + } else { + testCase.data.keyNames.map(async (keyName) => { + expect(await rte.client.exists(keyName)).to.eql(0); + }); + } + }); +}; + +describe('DELETE /instance/:instanceId/keys', () => { + before(async () => await rte.data.generateKeys(true)); + + // todo: investigate BE validation pipe with transform:true flag. Seems like works incorrect + xdescribe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should remove string', + data: { + keyNames: [constants.TEST_STRING_KEY_1], + }, + responseSchema, + responseBody: { + affected: 1, + }, + }, + { + name: 'Should remove list', + data: { + keyNames: [constants.TEST_LIST_KEY_1], + }, + responseSchema, + responseBody: { + affected: 1, + }, + }, + { + name: 'Should remove set', + data: { + keyNames: [constants.TEST_SET_KEY_1], + }, + responseSchema, + responseBody: { + affected: 1, + }, + }, + { + name: 'Should remove zset', + data: { + keyNames: [constants.TEST_ZSET_KEY_1], + }, + responseSchema, + responseBody: { + affected: 1, + }, + }, + { + name: 'Should remove hash', + data: { + keyNames: [constants.TEST_HASH_KEY_1], + }, + responseSchema, + responseBody: { + affected: 1, + }, + }, + { + name: 'Should remove multiple keys', + data: { + keyNames: [ + constants.TEST_STRING_KEY_1, + constants.TEST_LIST_KEY_1, + constants.TEST_SET_KEY_1, + constants.TEST_ZSET_KEY_1, + constants.TEST_HASH_KEY_1, + ], + }, + responseSchema, + responseBody: { + affected: 5, + }, + before: async function () { + // generate already deleted keys again + await rte.data.generateKeys(true) + this.data.keyNames.map(async (keyName) => { + expect(await rte.client.exists(keyName)).to.eql(1); + }); + } + }, + { + name: 'Should return NotFound error for not existing error', + data: { + keyNames: [constants.getRandomString()], + }, + statusCode: 404, + // todo: investigate error payload. Seems that missed fields and wrong message + responseBody: { + statusCode: 404, + message: 'Not Found', + }, + }, + ].map(mainCheckFn); + + describe('ReJSON-RL', () => { + requirements('rte.modules.rejson'); + [ + { + name: 'Should remove ReJSON', + data: { + keyNames: [constants.TEST_REJSON_KEY_1], + }, + responseSchema, + responseBody: { + affected: 1, + }, + }, + ].map(mainCheckFn); + }); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should remove key', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyNames: [constants.TEST_STRING_KEY_1], + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "del" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyNames: [constants.TEST_STRING_KEY_1], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -del') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/keys/GET-instance-id-keys.test.ts b/redisinsight/api/test/api/keys/GET-instance-id-keys.test.ts new file mode 100644 index 0000000000..7290d76d0f --- /dev/null +++ b/redisinsight/api/test/api/keys/GET-instance-id-keys.test.ts @@ -0,0 +1,865 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + validateApiCall, +} from '../deps'; +import { initSettings, setAppSettings } from '../../helpers/local-db'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).get(`/instance/${instanceId}/keys`); + +const responseSchema = Joi.array().items(Joi.object().keys({ + total: Joi.number().integer().required(), + scanned: Joi.number().integer().required(), + cursor: Joi.number().integer().required(), + host: Joi.string(), + port: Joi.number().integer(), + keys: Joi.array().items(Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().required(), + ttl: Joi.number().integer().required(), + size: Joi.number().integer(), // todo: fix size pipeline for cluster + })).required(), +}).required()).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('GET /instance/:instanceId/keys', () => { + // todo: add query validation + xdescribe('Validation', () => {}); + + describe('Common', () => { + const KEYS_NUMBER = 1500; // 300 per each base type + before(async () => await rte.data.generateNKeys(KEYS_NUMBER, true)); + + describe('Search (standalone + cluster)', () => { + [ + { + name: 'Should find key by exact name', + query: { + cursor: '0', + match: 'str_key_1' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.eq(1); + expect(result.keys[0].name).to.eq('str_key_1'); + } + }, + { + name: 'Should not find key by exact name', + query: { + cursor: '0', + match: 'not_exist_key' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).gte(KEYS_NUMBER); + expect(result.keys.length).to.eq(0); + } + }, + { + name: 'Should prevent full scan in one request', + query: { + count: 100, + cursor: '0', + match: 'not_exist_key*' + }, + responseSchema, + before: async () => await setAppSettings({ scanThreshold: 500 }), + after: async () => await initSettings(), + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(500).lte((500 + 100) * result.numberOfShards); + expect(result.keys.length).to.eql(0); + } + }, + { + name: 'Should search by with * in the end', + query: { + cursor: '0', + match: 'str_key_11*' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.gte(11); + result.keys.map(({ name }) => { + expect(name.indexOf('str_key_11')).to.eql(0); + }) + } + }, + { + name: 'Should search by with * in the beginning', + query: { + cursor: '0', + match: '*_key_111' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.gte(5); + result.keys.map(({ name }) => { + expect(name.indexOf('_key_111')).to.eql(name.length - 8); + }) + } + }, + { + name: 'Should search by with * in the middle', + query: { + cursor: '0', + match: 'str_*_111' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.eq(1); + expect(result.keys[0].name).to.eq('str_key_111'); + } + }, + { + name: 'Should search by with ? in the end', + query: { + cursor: '0', + match: 'str_key_10?' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.gte(10); + result.keys.map(({ name }) => { + expect(name.indexOf('str_key_10')).to.eql(0); + }) + } + }, + { + name: 'Should search by with [a-b] glob pattern', + query: { + cursor: '0', + match: 'str_key_10[0-5]' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.gte(1).lte(6); + result.keys.map(({ name }) => { + expect(name.indexOf('str_key_10')).to.eql(0); + }) + } + }, + { + name: 'Should search by with [a,b,c] glob pattern', + query: { + cursor: '0', + match: 'str_key_10[0,1,2]' + }, + responseSchema, + checkFn: ({body}) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.gte(1).lte(3); + result.keys.map(({name}) => { + expect(name.indexOf('str_key_10')).to.eql(0); + }) + } + }, + { + name: 'Should search by with [abc] glob pattern', + query: { + cursor: '0', + match: 'str_key_10[012]' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.gte(1).lte(3); + result.keys.map(({ name }) => { + expect(name.indexOf('str_key_10')).to.eql(0); + }) + } + }, + { + name: 'Should search by with [^a] glob pattern', + query: { + cursor: '0', + match: 'str_key_10[^0]' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.gte(9); + result.keys.map(({ name }) => { + expect(name.indexOf('str_key_10')).to.eql(0); + }) + } + }, + { + name: 'Should search by with combined glob patterns', + query: { + cursor: '0', + match: 's?r_*_[1][0-5][^0]' + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(KEYS_NUMBER); + expect(result.keys.length).to.gte(54); + } + }, + ].map(mainCheckFn); + }); + + describe('Standalone', () => { + requirements('rte.type=STANDALONE'); + + [ + { + name: 'Should scan all types', + query: { + cursor: '0', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.eql(KEYS_NUMBER); + expect(body[0].scanned).to.eql(200); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + } + }, + { + name: 'Should scan by provided count value', + query: { + count: 500, + cursor: '0', + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(500).lte(510); + expect(result.keys.length).to.gte(500).lte(510); + } + }, + ].map(mainCheckFn); + + it('Should scan entire database', async () => { + const keys = []; + let cursor = null; + let scanned = 0; + + while (cursor !== 0) { + await validateApiCall({ + endpoint, + query: { + cursor: cursor || 0, + count: 99, + }, + checkFn: ({ body }) => { + cursor = body[0].cursor; + scanned += body[0].scanned; + keys.push(...body[0].keys); + }, + }); + } + + expect(keys.length).to.be.gte(KEYS_NUMBER); + expect(keys.length).to.be.lt(KEYS_NUMBER + 5); // redis returns each key at least once + expect(cursor).to.eql(0); + expect(scanned).to.be.gte(KEYS_NUMBER); + expect(scanned).to.be.lt(KEYS_NUMBER + 99); + }); + + describe('Filter by type', () => { + requirements('rte.version>=6.0'); + + [ + { + name: 'Should filter by type (string)', + query: { + cursor: '0', + type: 'string', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.eql(KEYS_NUMBER); + expect(body[0].scanned).to.gte(200); + expect(body[0].scanned).to.lte(KEYS_NUMBER); + expect(body[0].scanned % 200).to.lte(0); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + expect(body[0].keys.length).to.lt(300); + body[0].keys.map(key => expect(key.name).to.have.string('str_key_')); + body[0].keys.map(key => expect(key.type).to.eql('string')); + } + }, + { + name: 'Should filter by type (list)', + query: { + cursor: '0', + type: 'list', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.eql(KEYS_NUMBER); + expect(body[0].scanned).to.gte(200); + expect(body[0].scanned).to.lte(KEYS_NUMBER); + expect(body[0].scanned % 200).to.lte(0); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + expect(body[0].keys.length).to.lt(300); + body[0].keys.map(key => expect(key.name).to.have.string('list_key_')); + body[0].keys.map(key => expect(key.type).to.eql('list')); + } + }, + { + name: 'Should filter by type (set)', + query: { + cursor: '0', + type: 'set', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.eql(KEYS_NUMBER); + expect(body[0].scanned).to.gte(200); + expect(body[0].scanned).to.lte(KEYS_NUMBER); + expect(body[0].scanned % 200).to.lte(0); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + expect(body[0].keys.length).to.lt(300); + body[0].keys.map(key => expect(key.name).to.have.string('set_key_')); + body[0].keys.map(key => expect(key.type).to.eql('set')); + } + }, + { + name: 'Should filter by type (zset)', + query: { + cursor: '0', + type: 'zset', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.eql(KEYS_NUMBER); + expect(body[0].scanned).to.gte(200); + expect(body[0].scanned).to.lte(KEYS_NUMBER); + expect(body[0].scanned % 200).to.lte(0); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + expect(body[0].keys.length).to.lt(300); + body[0].keys.map(key => expect(key.name).to.have.string('zset_key_')); + body[0].keys.map(key => expect(key.type).to.eql('zset')); + } + }, + { + name: 'Should filter by type (hash)', + query: { + cursor: '0', + type: 'hash', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.eql(KEYS_NUMBER); + expect(body[0].scanned).to.gte(200); + expect(body[0].scanned).to.lte(KEYS_NUMBER); + expect(body[0].scanned % 200).to.lte(0); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + expect(body[0].keys.length).to.lt(300); + body[0].keys.map(key => expect(key.name).to.have.string('hash_key_')); + body[0].keys.map(key => expect(key.type).to.eql('hash')); + } + }, + ].map(mainCheckFn); + + describe('REJSON-RL', () => { + requirements('rte.modules.rejson'); + before(async () => await rte.data.generateNReJSONs(300, false)); + + [ + { + name: 'Should filter by type (ReJSON-RL)', + query: { + cursor: '0', + type: 'ReJSON-RL', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.gte(KEYS_NUMBER); + expect(body[0].scanned).to.gte(200); + expect(body[0].scanned % 200).to.lte(0); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + expect(body[0].keys.length).to.lt(300); + body[0].keys.map(key => expect(key.name).to.have.string('rejson_key_')); + body[0].keys.map(key => expect(key.type).to.eql('ReJSON-RL')); + } + }, + ].map(mainCheckFn); + }); + describe('TSDB-TYPE', () => { + requirements('rte.modules.timeseries'); + before(async () => await rte.data.generateNTimeSeries(300, false)); + + [ + { + name: 'Should filter by type (timeseries)', + query: { + cursor: '0', + type: 'TSDB-TYPE', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.gte(KEYS_NUMBER); + expect(body[0].scanned).to.gte(200); + expect(body[0].scanned % 200).to.lte(0); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + expect(body[0].keys.length).to.lt(300); + body[0].keys.map(key => expect(key.name).to.have.string('ts_key_')); + body[0].keys.map(key => expect(key.type).to.eql('TSDB-TYPE')); + } + }, + ].map(mainCheckFn); + }); + describe('Stream', () => { + requirements('rte.version>=5.0'); + before(async () => await rte.data.generateNStreams(300, false)); + + [ + { + name: 'Should filter by type (stream)', + query: { + cursor: '0', + type: 'stream', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.gte(KEYS_NUMBER); + expect(body[0].scanned).to.gte(200); + expect(body[0].scanned % 200).to.lte(0); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + expect(body[0].keys.length).to.lt(300); + body[0].keys.map(key => expect(key.name).to.have.string('st_key_')); + body[0].keys.map(key => expect(key.type).to.eql('stream')); + } + }, + ].map(mainCheckFn); + }); + describe('Graph', () => { + requirements('rte.modules.graph'); + before(async () => await rte.data.generateNGraphs(300, false)); + + [ + { + name: 'Should filter by type (stream)', + query: { + cursor: '0', + type: 'graphdata', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.gte(KEYS_NUMBER); + expect(body[0].scanned).to.gte(200); + expect(body[0].scanned % 200).to.lte(0); + expect(body[0].cursor).to.not.eql(0); + expect(body[0].keys.length).to.gte(200); + expect(body[0].keys.length).to.lt(300); + body[0].keys.map(key => expect(key.name).to.have.string('graph_key_')); + body[0].keys.map(key => expect(key.type).to.eql('graphdata')); + } + }, + ].map(mainCheckFn); + }); + }); + + describe('Exact search on huge keys number', () => { + requirements('rte.onPremise'); + // Number of keys to generate. Could be 10M or even more but consume much more time + // We decide to generate 500K which should take ~10s + const NUMBER_OF_KEYS = 500 * 1000; + before(async () => await rte.data.generateHugeNumberOfTinyStringKeys(NUMBER_OF_KEYS, true)); + + [ + { + name: 'Should scan all types', + query: { + cursor: '0', + match: 'k_488500' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body[0].total).to.eql(NUMBER_OF_KEYS); + expect(body[0].scanned).to.eql(NUMBER_OF_KEYS); + expect(body[0].cursor).to.eql(0); + expect(body[0].keys.length).to.eql(1); + expect(body[0].keys[0].name).to.eql('k_488500'); + } + }, + ].map(mainCheckFn); + }); + }); + describe('Cluster', () => { + requirements('rte.type=CLUSTER'); + + [ + { + name: 'Should scan all types', + query: { + cursor: '0', + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + expect(shard.scanned).to.eql(200); + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.eql(200 * result.numberOfShards); + expect(result.keys.length).to.gte(200 * result.numberOfShards); + } + }, + { + name: 'Should scan by provided count value', + query: { + count: 300, + cursor: '0', + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(300 * result.numberOfShards).lte(310 * result.numberOfShards); + expect(result.keys.length).to.gte(300 * result.numberOfShards).lte(310 * result.numberOfShards); + } + }, + ].map(mainCheckFn); + + it('Should scan entire database', async () => { + const keys = []; + let scanned = 0; + let cursor = ['0']; + while (cursor.length > 0) { + await validateApiCall({ + endpoint, + query: { + cursor: cursor.join('||'), + count: 99, + }, + checkFn: ({ body }) => { + cursor = []; + body.map(shard => { + if (shard.cursor !== 0) { + cursor.push(`${shard.host}:${shard.port}@${shard.cursor}`); + } + scanned += shard.scanned; + keys.push(...shard.keys); + }); + }, + }); + } + + expect(keys.length).to.be.gte(KEYS_NUMBER); + expect(cursor).to.eql([]); + expect(scanned).to.be.gte(KEYS_NUMBER); + }); + + describe('Filter by type', () => { + requirements('rte.version>=6.0'); + [ + { + name: 'Should filter by type (string)', + query: { + cursor: '0', + type: 'string', + count: 200, + }, + responseSchema, + checkFn: ({ body }) => { + const result = { + total: 0, + scanned: 0, + keys: [], + numberOfShards: 0, + }; + + body.map(shard => { + result.total += shard.total; + result.scanned += shard.scanned; + result.keys.push(...shard.keys); + result.numberOfShards++; + expect(shard.scanned).to.gte(200); + expect(shard.scanned).to.lte(KEYS_NUMBER); + }); + expect(result.total).to.eql(KEYS_NUMBER); + expect(result.scanned).to.gte(200 * result.numberOfShards); + expect(result.keys.length).to.gte(200); + result.keys.map(key => expect(key.name).to.have.string('str_key_')); + result.keys.map(key => expect(key.type).to.eql('string')); + } + }, + ].map(mainCheckFn); + }); + }); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => await rte.data.generateKeys(true)); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should remove key', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + query: { + cursor: '0', + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "scan" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + query: { + cursor: '0', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -scan') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/keys/PATCH-instance-id-keys-name.test.ts b/redisinsight/api/test/api/keys/PATCH-instance-id-keys-name.test.ts new file mode 100644 index 0000000000..73e4917515 --- /dev/null +++ b/redisinsight/api/test/api/keys/PATCH-instance-id-keys-name.test.ts @@ -0,0 +1,199 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).patch(`/instance/${instanceId}/keys/name`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + newKeyName: Joi.string().allow('').required(), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + newKeyName: constants.getRandomString(), +}; + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + if (testCase.before) { + await testCase.before(); + } else { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(1); + expect(await rte.client.exists(testCase.data.newKeyName)).to.eql(0); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + if (testCase.after) { + await testCase.after(); + } else { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(0); + expect(await rte.client.exists(testCase.data.newKeyName)).to.eql(1); + } + }); +}; + +describe('PATCH /instance/:instanceId/keys/name', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should rename string', + data: { + keyName: constants.TEST_STRING_KEY_1, + newKeyName: constants.getRandomString() + constants.CLUSTER_HASH_SLOT, + }, + responseSchema, + }, + { + name: 'Should rename list', + data: { + keyName: constants.TEST_LIST_KEY_1, + newKeyName: constants.getRandomString() + constants.CLUSTER_HASH_SLOT, + }, + responseSchema, + }, + { + name: 'Should rename set', + data: { + keyName: constants.TEST_SET_KEY_1, + newKeyName: constants.getRandomString() + constants.CLUSTER_HASH_SLOT, + }, + responseSchema, + }, + { + name: 'Should rename zset', + data: { + keyName: constants.TEST_ZSET_KEY_1, + newKeyName: constants.getRandomString() + constants.CLUSTER_HASH_SLOT, + }, + responseSchema, + }, + { + name: 'Should rename hash', + data: { + keyName: constants.TEST_HASH_KEY_1, + newKeyName: constants.getRandomString() + constants.CLUSTER_HASH_SLOT, + }, + responseSchema, + }, + { + name: 'Should return NotFound error for not existing error', + data: { + keyName: constants.getRandomString(), + newKeyName: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + before: async function () { + expect(await rte.client.exists(this.data.keyName)).to.eql(0); + expect(await rte.client.exists(this.data.newKeyName)).to.eql(0); + }, + after: async function () { + expect(await rte.client.exists(this.data.keyName)).to.eql(0); + expect(await rte.client.exists(this.data.newKeyName)).to.eql(0); + } + }, + ].map(mainCheckFn); + + describe('ReJSON-RL', () => { + requirements('rte.modules.rejson'); + [ + { + name: 'Should rename ReJSON', + data: { + keyName: constants.TEST_REJSON_KEY_1, + newKeyName: constants.getRandomString(), + }, + }, + ].map(mainCheckFn); + }); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => await rte.data.generateKeys(true)); + + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should rename key', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + newKeyName: constants.getRandomString() + constants.CLUSTER_HASH_SLOT, + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + newKeyName: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists'), + after: async function () { + expect(await rte.client.exists(this.data.keyName)).to.eql(1); + expect(await rte.client.exists(this.data.newKeyName)).to.eql(0); + } + }, + { + name: 'Should throw error if no permissions for "renamenx" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + newKeyName: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -renamenx'), + after: async function () { + expect(await rte.client.exists(this.data.keyName)).to.eql(1); + expect(await rte.client.exists(this.data.newKeyName)).to.eql(0); + } + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/keys/PATCH-instance-id-keys-ttl.test.ts b/redisinsight/api/test/api/keys/PATCH-instance-id-keys-ttl.test.ts new file mode 100644 index 0000000000..d895f17fde --- /dev/null +++ b/redisinsight/api/test/api/keys/PATCH-instance-id-keys-ttl.test.ts @@ -0,0 +1,163 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).patch(`/instance/${instanceId}/keys/ttl`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + ttl: Joi.number().integer().max(2147483647).required().messages({ + 'any.required': '{#label} should not be empty' + }), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + ttl: 12, +}; + +const responseSchema = Joi.object().keys({ + ttl: Joi.number().integer().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PATCH /instance/:instanceId/keys/ttl', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should set ttl for key', + data: { + keyName: constants.TEST_STRING_KEY_2, + ttl: 300, + }, + responseSchema, + after: async () => { + expect(await rte.client.ttl(constants.TEST_STRING_KEY_2)).to.eql(300) + } + }, + { + name: 'Should remove ttl for key', + data: { + keyName: constants.TEST_STRING_KEY_2, + ttl: -1, + }, + responseSchema, + after: async () => { + expect(await rte.client.ttl(constants.TEST_STRING_KEY_2)).to.eql(-1) + } + }, + { + name: 'Should return NotFound error for not existing key error', + data: { + keyName: constants.getRandomString(), + ttl: 12, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + ].map(mainCheckFn); + + describe('ReJSON-RL', () => { + requirements('rte.modules.rejson'); + [ + { + name: 'Should set ttl for ReJSON', + data: { + keyName: constants.TEST_REJSON_KEY_1, + ttl: 3, + }, + }, + ].map(mainCheckFn); + }); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => await rte.data.generateKeys(true)); + + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should set ttl for key', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + ttl: 10, + }, + after: async () => { + expect(await rte.client.ttl(constants.TEST_STRING_KEY_1)).to.eql(10) + } + }, + { + name: 'Should throw error if no permissions for "persist" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + ttl: -1, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -persist'), + }, + { + name: 'Should throw error if no permissions for "expire" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + ttl: 30, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -expire'), + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/keys/POST-instance-id-keys-get_info.test.ts b/redisinsight/api/test/api/keys/POST-instance-id-keys-get_info.test.ts new file mode 100644 index 0000000000..b61f3f5916 --- /dev/null +++ b/redisinsight/api/test/api/keys/POST-instance-id-keys-get_info.test.ts @@ -0,0 +1,292 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/keys/get-info`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), +}).strict(); + +const validInputData = { + keyName: constants.TEST_LIST_KEY_1, +}; + +const responseSchema = Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().required(), + ttl: Joi.number().integer().required(), + size: Joi.number().integer().required(), + length: Joi.number().integer().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('POST /instance/:instanceId/keys/get-info', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should return string info', + data: { + keyName: constants.TEST_STRING_KEY_1, + }, + responseSchema, + responseBody: { + name: constants.TEST_STRING_KEY_1, + type: constants.TEST_STRING_TYPE, + ttl: -1, + length: constants.TEST_STRING_VALUE_1.length, + }, + }, + { + name: 'Should return list info', + data: { + keyName: constants.TEST_LIST_KEY_1, + }, + responseSchema, + responseBody: { + name: constants.TEST_LIST_KEY_1, + type: constants.TEST_LIST_TYPE, + ttl: -1, + length: 2, + }, + }, + { + name: 'Should return set info', + data: { + keyName: constants.TEST_SET_KEY_1, + }, + responseSchema, + responseBody: { + name: constants.TEST_SET_KEY_1, + type: constants.TEST_SET_TYPE, + ttl: -1, + length: 1, + }, + }, + { + name: 'Should return zset info', + data: { + keyName: constants.TEST_ZSET_KEY_1, + }, + responseSchema, + responseBody: { + name: constants.TEST_ZSET_KEY_1, + type: constants.TEST_ZSET_TYPE, + ttl: -1, + length: 2, + }, + }, + { + name: 'Should return hash info', + data: { + keyName: constants.TEST_HASH_KEY_1, + }, + responseSchema, + responseBody: { + name: constants.TEST_HASH_KEY_1, + type: constants.TEST_HASH_TYPE, + ttl: -1, + length: 2, + }, + }, + { + name: 'Should return NotFound error for not existing error', + data: { + keyName: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + ].map(mainCheckFn); + + describe('ReJSON-RL', () => { + requirements('rte.modules.rejson'); + [ + { + name: 'Should return ReJSON info', + data: { + keyName: constants.TEST_REJSON_KEY_1, + }, + responseSchema, + responseBody: { + name: constants.TEST_REJSON_KEY_1, + type: constants.TEST_REJSON_TYPE, + ttl: -1, + length: 1, + }, + }, + ].map(mainCheckFn); + }); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + const mainACLCheckFn = async (testCase) => { + it(testCase.name, async () => { + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + ...testCase, + checkFn: ({ body }) => { + expect(body.ttl).to.eql(undefined); + expect(body.length).to.eql(undefined); + expect(body.size).to.eql(null); + } + }); + }); + }; + + [ + { + name: 'Should return key info', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "type" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -type') + }, + ].map(mainCheckFn); + + [ + { + name: 'Should return empty fields if no permission for (ttl, memory, strlen)', + data: { + keyName: constants.TEST_STRING_KEY_1, + }, + responseBody: { + name: constants.TEST_STRING_KEY_1, + type: constants.TEST_STRING_TYPE, + }, + before: () => rte.data.setAclUserRules('~* +@all -ttl -memory -strlen'), + }, + { + name: 'Should return empty fields if no permission for (ttl, memory, llen)', + data: { + keyName: constants.TEST_LIST_KEY_1, + }, + responseBody: { + name: constants.TEST_LIST_KEY_1, + type: constants.TEST_LIST_TYPE, + }, + before: () => rte.data.setAclUserRules('~* +@all -ttl -memory -llen'), + }, + { + name: 'Should return empty fields if no permission for (ttl, memory, scard)', + data: { + keyName: constants.TEST_SET_KEY_1, + }, + responseBody: { + name: constants.TEST_SET_KEY_1, + type: constants.TEST_SET_TYPE, + }, + before: () => rte.data.setAclUserRules('~* +@all -ttl -memory -scard'), + }, + { + name: 'Should return empty fields if no permission for (ttl, memory, zcard)', + data: { + keyName: constants.TEST_ZSET_KEY_1, + }, + responseBody: { + name: constants.TEST_ZSET_KEY_1, + type: constants.TEST_ZSET_TYPE, + }, + before: () => rte.data.setAclUserRules('~* +@all -ttl -memory -zcard'), + }, + { + name: 'Should return empty fields if no permission for (ttl, memory, zcard)', + data: { + keyName: constants.TEST_ZSET_KEY_1, + }, + responseBody: { + name: constants.TEST_ZSET_KEY_1, + type: constants.TEST_ZSET_TYPE, + }, + before: () => rte.data.setAclUserRules('~* +@all -ttl -memory -zcard'), + }, + { + name: 'Should return empty fields if no permission for (ttl, memory, usage, hlen)', + data: { + keyName: constants.TEST_HASH_KEY_1, + }, + responseBody: { + name: constants.TEST_HASH_KEY_1, + type: constants.TEST_HASH_TYPE, + }, + before: () => rte.data.setAclUserRules('~* +@all -ttl -memory -hlen'), + }, + ].map(mainACLCheckFn); + //json.type + describe('ReJSON-RL', () => { + requirements('rte.modules.rejson'); + + [ + { + name: 'Should return empty fields if no permission for (ttl, memory, json.type)', + data: { + keyName: constants.TEST_REJSON_KEY_1, + }, + responseBody: { + name: constants.TEST_REJSON_KEY_1, + type: constants.TEST_REJSON_TYPE, + }, + before: () => rte.data.setAclUserRules('~* +@all -ttl -memory -json.type'), + }, + ].map(mainACLCheckFn); + }); + }); +}); diff --git a/redisinsight/api/test/api/list/DELETE-instance-id-list-elements.test.ts b/redisinsight/api/test/api/list/DELETE-instance-id-list-elements.test.ts new file mode 100644 index 0000000000..a2e19bc68c --- /dev/null +++ b/redisinsight/api/test/api/list/DELETE-instance-id-list-elements.test.ts @@ -0,0 +1,234 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).delete(`/instance/${instanceId}/list/elements`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + destination: Joi.string().required().valid('HEAD', 'TAIL'), + count: Joi.number().integer().min(1) + .allow(true), // todo: investigate/fix BE payload transform function +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + destination: 'TAIL', + count: 2, +}; + +const responseSchema = Joi.object().keys({ + elements: Joi.array().items(Joi.string()).required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('DELETE /instance/:instanceId/list/elements', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + describe('Only one element for redis < 6.2', () => { + requirements('rte.version<6.2'); + before(async () => await rte.data.generateKeys(true)); + + [ + { + name: 'Should delete 1 element from the tail', + data: { + keyName: constants.TEST_LIST_KEY_2, + destination: 'TAIL', + count: 1, + }, + responseSchema, + responseBody: { + elements: ['element_100'], + }, + after: async () => { + const elements = await rte.client.lrange(constants.TEST_LIST_KEY_2, 0, 1000); + expect(elements.length).to.eql(99); + expect(elements[0]).to.eql('element_1') + expect(elements[98]).to.eql('element_99') + }, + }, + { + name: 'Should delete 1 element from the head', + data: { + keyName: constants.TEST_LIST_KEY_2, + destination: 'HEAD', + count: 1, + }, + responseSchema, + responseBody: { + elements: ['element_1'], + }, + after: async () => { + const elements = await rte.client.lrange(constants.TEST_LIST_KEY_2, 0, 1000); + expect(elements.length).to.eql(98); + expect(elements[0]).to.eql('element_2') + expect(elements[97]).to.eql('element_99') + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + destination: 'TAIL', + count: 1 + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + describe('Multiple elements for redis >= 6.2', () => { + requirements('rte.version>=6.2'); + before(async () => await rte.data.generateKeys(true)); + + [ + { + name: 'Should delete 2 element from the tail', + data: { + keyName: constants.TEST_LIST_KEY_2, + destination: 'TAIL', + count: 2, + }, + responseSchema, + responseBody: { + elements: ['element_100', 'element_99'], + }, + after: async () => { + const elements = await rte.client.lrange(constants.TEST_LIST_KEY_2, 0, 1000); + expect(elements.length).to.eql(98); + expect(elements[0]).to.eql('element_1') + expect(elements[97]).to.eql('element_98') + }, + }, + { + name: 'Should delete 10 elements from the head', + data: { + keyName: constants.TEST_LIST_KEY_2, + destination: 'HEAD', + count: 10, + }, + responseBody: { + elements: (new Array(10).fill(0)).map((item, i) => `element_${i + 1}`), + }, + responseSchema, + after: async () => { + const elements = await rte.client.lrange(constants.TEST_LIST_KEY_2, 0, 1000); + expect(elements.length).to.eql(88); + expect(elements[0]).to.eql('element_11') + expect(elements[87]).to.eql('element_98') + }, + }, + { + name: 'Should delete all elements and key', + data: { + keyName: constants.TEST_LIST_KEY_2, + destination: 'HEAD', + count: 88, + }, + responseBody: { + elements: (new Array(88).fill(0)).map((item, i) => `element_${i + 11}`), + }, + responseSchema, + before: async () => { + expect(await rte.client.exists(constants.TEST_LIST_KEY_2)).to.eql(1); + }, + after: async () => { + expect(await rte.client.exists(constants.TEST_LIST_KEY_2)).to.eql(0); + }, + }, + ].map(mainCheckFn); + }); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + destination: 'TAIL', + count: 1 + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "lpop" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + destination: 'HEAD', + count: 1 + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -lpop') + }, + { + name: 'Should throw error if no permissions for "rpop" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + destination: 'TAIL', + count: 1 + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -rpop') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/list/PATCH-instance-id-list.test.ts b/redisinsight/api/test/api/list/PATCH-instance-id-list.test.ts new file mode 100644 index 0000000000..e3e3f215f4 --- /dev/null +++ b/redisinsight/api/test/api/list/PATCH-instance-id-list.test.ts @@ -0,0 +1,183 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).patch(`/instance/${instanceId}/list`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + element: Joi.string().required(), + index: Joi.number().integer(), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + element: constants.TEST_LIST_ELEMENT_1, + index: 0, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PATCH /instance/:instanceId/list', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should modify item with empty value on position 0', + data: { + keyName: constants.TEST_LIST_KEY_1, + element: '', + index: 0, + }, + statusCode: 200, + after: async () => { + expect(await rte.client.lrange(constants.TEST_LIST_KEY_1, 0, 100)).to.eql([ + '', + constants.TEST_LIST_ELEMENT_2, + ]); + } + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + element: constants.getRandomString(), + index: 0, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + element: constants.getRandomString(), + index: 0, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return BadRequest error if index is out of range', + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + index: 999, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + index: 0, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.TEST_LIST_ELEMENT_1, + index: 0, + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "lset" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + index: 0, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -lset') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + index: 0, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/list/POST-instance-id-list-get_elements-index.test.ts b/redisinsight/api/test/api/list/POST-instance-id-list-get_elements-index.test.ts new file mode 100644 index 0000000000..a48fb0e82c --- /dev/null +++ b/redisinsight/api/test/api/list/POST-instance-id-list-get_elements-index.test.ts @@ -0,0 +1,196 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID, index = 0) => + request(server).post(`/instance/${instanceId}/list/get-elements/${index}`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), +}; + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), + value: Joi.string().allow('').required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/list/get-elements/:index', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + before(async () => await rte.data.generateKeys(true)); + + [ + { + name: 'Should select key from position 0 (by default)', + data: { + keyName: constants.TEST_LIST_KEY_2, + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_2, + value: 'element_1', + }, + }, + { + endpoint: () => endpoint(constants.TEST_INSTANCE_ID, 0), + name: 'Should select key from position 0', + data: { + keyName: constants.TEST_LIST_KEY_2, + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_2, + value: 'element_1', + }, + }, + { + endpoint: () => endpoint(constants.TEST_INSTANCE_ID, 1), + name: 'Should select key from position 1', + data: { + keyName: constants.TEST_LIST_KEY_2, + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_2, + value: 'element_2', + }, + }, + { + endpoint: () => endpoint(constants.TEST_INSTANCE_ID, 99), + name: 'Should select key from position 99', + data: { + keyName: constants.TEST_LIST_KEY_2, + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_2, + value: 'element_100', + }, + }, + { + endpoint: () => endpoint(constants.TEST_INSTANCE_ID, -1), + name: 'Should select key from position -1', + data: { + keyName: constants.TEST_LIST_KEY_2, + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_2, + value: 'element_100', + }, + }, + { + endpoint: () => endpoint(constants.TEST_INSTANCE_ID, -2), + name: 'Should select key from position -2', + data: { + keyName: constants.TEST_LIST_KEY_2, + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_2, + value: 'element_99', + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_2, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + offset: 0, + count: 1000, + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "lindex" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + offset: 0, + count: 1000, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -lindex') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/list/POST-instance-id-list-get_elements.test.ts b/redisinsight/api/test/api/list/POST-instance-id-list-get_elements.test.ts new file mode 100644 index 0000000000..efae5986c4 --- /dev/null +++ b/redisinsight/api/test/api/list/POST-instance-id-list-get_elements.test.ts @@ -0,0 +1,193 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/list/get-elements`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + offset: Joi.number().integer().min(0) + .allow(true), // todo: investigate/fix BE payload transform function + count: Joi.number().integer().min(1) + .allow(true), // todo: investigate/fix BE payload transform function +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + offset: 0, + count: 20, +}; + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), + total: Joi.number().integer().required(), + elements: Joi.array().items(Joi.string()).required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/list/get-elements', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + before(async () => await rte.data.generateKeys(true)); + + [ + { + name: 'Should select all keys', + data: { + keyName: constants.TEST_LIST_KEY_2, + offset: 0, + count: 1000, + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_2, + total: 100, + elements: (new Array(100).fill(0)).map((item, i) => `element_${i + 1}`), + }, + }, + { + name: 'Should select last 50 keys', + data: { + keyName: constants.TEST_LIST_KEY_2, + offset: 50, + count: 1000, + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_2, + total: 100, + elements: (new Array(50).fill(0)).map((item, i) => `element_${i + 51}`), + }, + }, + { + name: 'Should select first 50 keys', + data: { + keyName: constants.TEST_LIST_KEY_2, + offset: 0, + count: 50, + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_2, + total: 100, + elements: (new Array(50).fill(0)).map((item, i) => `element_${i + 1}`), + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + offset: 0, + count: 1000, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_2, + offset: 0, + count: 1000, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + offset: 0, + count: 1000, + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "llen" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + offset: 0, + count: 1000, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -llen') + }, + { + name: 'Should throw error if no permissions for "lrange" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + offset: 0, + count: 1000, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -lrange') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/list/POST-instance-id-list.test.ts b/redisinsight/api/test/api/list/POST-instance-id-list.test.ts new file mode 100644 index 0000000000..a3212853d2 --- /dev/null +++ b/redisinsight/api/test/api/list/POST-instance-id-list.test.ts @@ -0,0 +1,177 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/list`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + element: Joi.string().required(), + expire: Joi.number().integer().allow(null).min(1).max(2147483647), +}).strict(); + +const validInputData = { + keyName: constants.TEST_LIST_KEY_1, + element: constants.TEST_LIST_ELEMENT_1, + expire: constants.TEST_LIST_EXPIRE_1, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(0); + } + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(1); + expect(await rte.client.lrange(testCase.data.keyName, 0, 100)).to.eql([testCase.data.element]); + expect(await rte.client.ttl(testCase.data.keyName)).to.eql(testCase.data.expire || -1); + } + } + }); +}; + +describe('POST /instance/:instanceId/list', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should create item with empty value', + data: { + keyName: constants.getRandomString(), + element: '', + }, + statusCode: 201, + }, + { + name: 'Should create item with key ttl', + data: { + keyName: constants.getRandomString(), + element: constants.getRandomString(), + expire: constants.TEST_STRING_EXPIRE_1, + }, + statusCode: 201, + }, + { + name: 'Should create regular item', + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.TEST_LIST_ELEMENT_1, + }, + statusCode: 201, + }, + { + name: 'Should return conflict error if key already exists', + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + }, + statusCode: 409, + responseBody: { + statusCode: 409, + error: 'Conflict', + message: 'This key name is already in use.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.lrange(constants.TEST_LIST_KEY_1, 0, 10)).to.eql([constants.TEST_LIST_ELEMENT_1]) + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.lrange(constants.TEST_LIST_KEY_1, 0, 10)).to.eql([constants.TEST_LIST_ELEMENT_1]) + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + element: constants.TEST_LIST_ELEMENT_1, + }, + statusCode: 201, + }, + { + name: 'Should throw error if no permissions for "lpush" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + element: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -lpush') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + element: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/list/PUT-instance-id-list.test.ts b/redisinsight/api/test/api/list/PUT-instance-id-list.test.ts new file mode 100644 index 0000000000..7fb15447f5 --- /dev/null +++ b/redisinsight/api/test/api/list/PUT-instance-id-list.test.ts @@ -0,0 +1,200 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).put(`/instance/${instanceId}/list`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + element: Joi.string().required(), + destination: Joi.string().valid('HEAD', 'TAIL'), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + element: constants.getRandomString(), + destination: 'TAIL', +}; + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), + total: Joi.number().integer().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PUT /instance/:instanceId/list', () => { + before(rte.data.truncate); + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + before(async () => await rte.data.generateKeys(true)); + + [ + { + name: 'Should insert 1 element to the tail (by default)', + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + destination: 'TAIL', + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_1, + total: 3, + }, + after: async function () { + const elements = await rte.client.lrange(constants.TEST_LIST_KEY_1, 0, 1000); + expect(elements[2]).to.eql(this.data.element); + }, + }, + { + name: 'Should insert 1 element to the tail', + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + destination: 'TAIL', + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_1, + total: 4, + }, + after: async function () { + const elements = await rte.client.lrange(constants.TEST_LIST_KEY_1, 0, 1000); + expect(elements[3]).to.eql(this.data.element); + }, + }, + { + name: 'Should insert 1 element to the head', + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + destination: 'HEAD', + }, + responseSchema, + responseBody: { + keyName: constants.TEST_LIST_KEY_1, + total: 5, + }, + after: async function () { + const elements = await rte.client.lrange(constants.TEST_LIST_KEY_1, 0, 1000); + expect(elements[0]).to.eql(this.data.element); + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + element: constants.getRandomString(), + destination: 'HEAD', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + destination: 'HEAD', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + destination: 'TAIL', + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "lpushx" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + destination: 'HEAD', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -lpushx') + }, + { + name: 'Should throw error if no permissions for "rpushx" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + element: constants.getRandomString(), + destination: 'TAIL', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -rpushx') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/plugins/GET-plugins.test.ts b/redisinsight/api/test/api/plugins/GET-plugins.test.ts new file mode 100644 index 0000000000..43b083637f --- /dev/null +++ b/redisinsight/api/test/api/plugins/GET-plugins.test.ts @@ -0,0 +1,46 @@ +import { describe, it, deps, validateApiCall } from '../deps'; +import { Joi } from '../../helpers/test'; +const { request, server } = deps; + +const endpoint = () => request(server).get(`/plugins`); + +const responseSchema = Joi.object().keys({ + static: Joi.string().required(), + plugins: Joi.array().items(Joi.object().keys({ + internal: Joi.boolean(), + name: Joi.string().required(), + baseUrl: Joi.string().required(), + main: Joi.string().required(), + styles: Joi.string(), + visualizations: Joi.array().items(Joi.object().keys({ + id: Joi.string().required(), + name: Joi.string().required(), + activationMethod: Joi.string().required(), + matchCommands: Joi.array().items(Joi.string().required()).required(), + default: Joi.boolean(), + iconDark: Joi.string(), + iconLight: Joi.string(), + }).required()).required(), + })).required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /plugins', () => { + [ + { + name: 'Should get plugin commands whitelist', + responseSchema, + checkFn: ({body}) => { + console.log('body', body) + } + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/rejson-rl/DELETE-instance-id-rejson_rl.test.ts b/redisinsight/api/test/api/rejson-rl/DELETE-instance-id-rejson_rl.test.ts new file mode 100644 index 0000000000..6e8628c191 --- /dev/null +++ b/redisinsight/api/test/api/rejson-rl/DELETE-instance-id-rejson_rl.test.ts @@ -0,0 +1,180 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + _, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).delete(`/instance/${instanceId}/rejson-rl`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + path: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + path: '.', +}; + +const responseSchema = Joi.object().keys({ + affected: Joi.number().integer().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('DELETE /instance/:instanceId/rejson-rl', () => { + requirements('rte.modules.rejson'); + + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should delete element from nested object by path', + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.object.field', + }, + responseSchema, + responseBody: { + affected: 1, + }, + after: async () => { + const json = JSON.parse(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_3, '.')); + expect(json).to.deep.eql(_.omit(constants.TEST_REJSON_VALUE_3, 'object.field')) + }, + }, + { + name: 'Should delete element from array by path', + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.array[1]', + }, + responseSchema, + responseBody: { + affected: 1, + }, + before: async () => { + const json = JSON.parse(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_3, '.')); + expect(json.array.length).to.eql(3); + }, + after: async () => { + const json = JSON.parse(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_3, '.')); + expect(json.array.length).to.eql(2); + }, + }, + { + name: 'Should not affect json if not existing path', + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.not_existing_path', + }, + responseSchema, + responseBody: { + affected: 0, + }, + }, + { + name: 'Should delete entire json and remove the key', + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.', + }, + responseSchema, + responseBody: { + affected: 1, + }, + after: async () => { + expect(await rte.client.exists(constants.TEST_REJSON_KEY_3)).to.eql(0); + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify(constants.getRandomString()), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => { + // check that value was not overwritten + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_1, '.')) + .to.deep.eql(JSON.stringify(constants.TEST_REJSON_VALUE_1)); + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + path: '.n', + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "json.del" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + path: '.n', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.del') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/rejson-rl/PATCH-instance-id-rejson_rl-arrappend.test.ts b/redisinsight/api/test/api/rejson-rl/PATCH-instance-id-rejson_rl-arrappend.test.ts new file mode 100644 index 0000000000..925d5bb78a --- /dev/null +++ b/redisinsight/api/test/api/rejson-rl/PATCH-instance-id-rejson_rl-arrappend.test.ts @@ -0,0 +1,160 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).patch(`/instance/${instanceId}/rejson-rl/arrappend`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + data: Joi.array().items(Joi.string().required().messages({ + 'any.required': '{#label} should be a correct serialized json string', + }).label('data')).required().messages({ + 'any.required': '{#label} must be an array', + 'array.sparse': 'each value in data must be a string', + }), + path: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + data: [JSON.stringify(constants.getRandomString())], + path: '.', +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PATCH /instance/:instanceId/rejson-rl/arrappend', () => { + requirements('rte.modules.rejson'); + + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should append array', + data: { + keyName: constants.TEST_REJSON_KEY_2, + data: [JSON.stringify([1, 2])], + path: '.' + }, + statusCode: 200, + after: async () => { + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_2, '.')) + .to.eql(JSON.stringify([...constants.TEST_REJSON_VALUE_2, [1, 2]])); + } + }, + { + name: 'Should append multiple items into array.array', + data: { + keyName: constants.TEST_REJSON_KEY_2, + data: [JSON.stringify(null), JSON.stringify('somestring')], + path: '[1]' + }, + statusCode: 200, + after: async () => { + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_2, '.')) + .to.eql(JSON.stringify([...constants.TEST_REJSON_VALUE_2, [1, 2, null, 'somestring']])); + } + }, + { + name: 'Should return BadRequest if try to append to not array item', + data: { + keyName: constants.TEST_REJSON_KEY_2, + data: [JSON.stringify(constants.getRandomString())], + path: '[1][1]' + }, + // todo: handle error to return 400 instead of 500 (BE) + statusCode: 500, + responseBody: { + statusCode: 500, + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_REJSON_KEY_2, + data: JSON.stringify(constants.getRandomString()), + path: '.' + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should modify json', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_2, + data: [JSON.stringify([1, 2])], + path: '.' + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "json.arrappend" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_2, + data: [JSON.stringify(constants.getRandomString())], + path: '.', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.arrappend') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/rejson-rl/PATCH-instance-id-rejson_rl-set.test.ts b/redisinsight/api/test/api/rejson-rl/PATCH-instance-id-rejson_rl-set.test.ts new file mode 100644 index 0000000000..2f666c3c72 --- /dev/null +++ b/redisinsight/api/test/api/rejson-rl/PATCH-instance-id-rejson_rl-set.test.ts @@ -0,0 +1,174 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).patch(`/instance/${instanceId}/rejson-rl/set`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + data: Joi.string().required().messages({ + 'any.required': '{#label} should be a correct serialized json string', + }), + path: Joi.string().required().messages({ + 'any.required': '{#label} should not be empty', + }), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + data: JSON.stringify(constants.TEST_REJSON_VALUE_1), + path: '.', +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PATCH /instance/:instanceId/rejson-rl/set', () => { + requirements('rte.modules.rejson'); + + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should modify item with empty value', + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify(''), + path: 'test' + }, + statusCode: 200, + after: async () => { + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_1, '.')) + .to.eql(JSON.stringify({ test: '' })); + } + }, + { + name: 'Should modify item with null value', + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify(null), + path: 'test' + }, + statusCode: 200, + after: async () => { + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_1, '.')) + .to.eql(JSON.stringify({ test: null })); + } + }, + { + name: 'Should modify item with array in the root', + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify([1, 2]), + path: '.' + }, + statusCode: 200, + after: async () => { + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_1, '.')) + .to.eql(JSON.stringify([1, 2])); + } + }, + { + name: 'Should modify item with object in the root', + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify({ test: 'test' }), + path: '.' + }, + statusCode: 200, + after: async () => { + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_1, '.')) + .to.eql(JSON.stringify({ test: 'test' })); + } + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify(constants.getRandomString()), + path: '.' + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should modify json', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify([1, 2]), + path: '.' + }, + statusCode: 200, + after: async () => { + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_1, '.')) + .to.eql(JSON.stringify([1, 2])); + } + }, + { + name: 'Should throw error if no permissions for "json.set" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify(constants.getRandomString()), + path: '.', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.set') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/rejson-rl/POST-instance-id-rejson_rl-get.test.ts b/redisinsight/api/test/api/rejson-rl/POST-instance-id-rejson_rl-get.test.ts new file mode 100644 index 0000000000..f8373a542c --- /dev/null +++ b/redisinsight/api/test/api/rejson-rl/POST-instance-id-rejson_rl-get.test.ts @@ -0,0 +1,305 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + _, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/rejson-rl/get`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + path: Joi.string(), + forceRetrieve: Joi.boolean(), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + path: '.', + forceRetrieve: false, +}; + +const responseSchema = Joi.object().keys({ + downloaded: Joi.boolean().required(), + path: Joi.string().required(), + type: Joi.string(), + data: Joi.any(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/rejson-rl/get', () => { + requirements('rte.modules.rejson'); + + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should force get entire json', + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.', + forceRetrieve: true, + }, + responseSchema, + responseBody: { + downloaded: true, + path: '.', + data: constants.TEST_REJSON_VALUE_3, + }, + }, + { + name: 'Should get nested object', + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.object.field', + forceRetrieve: false, + }, + responseSchema, + responseBody: { + downloaded: true, + path: '.object.field', + data: 'value', + }, + }, + { + name: 'Should get nested array value (downloaded true due to size)', + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '["array"][1]', + forceRetrieve: false, + }, + responseSchema, + responseBody: { + downloaded: true, + path: '["array"][1]', + data: 2, + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + path: '["object"]["some"]', + forceRetrieve: false, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('Large key value', () => { + // todo: do not forget to remove rte.name check after fixing MEMORY USAGE issue in RedisJSON v2.0.0 + requirements('rte.acl', '!rte.name=MODS_PREVIEW'); + [ + { + name: 'Should get json with calculated cardinality', + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.', + forceRetrieve: false, + }, + responseSchema, + responseBody: { + downloaded: false, + path: '.', + type: 'object', + data: [ + { + type: 'array', + key: 'array', + path: '["array"]', + cardinality: 3, + }, + { + type: 'object', + key: 'object', + path: '["object"]', + cardinality: 2, + } + ], + }, + }, + { + name: 'Should get safe large string from the object', // todo: do not forget to implement partially string download for JSON + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '["object"]["some"]', + forceRetrieve: false, + }, + responseSchema, + responseBody: { + downloaded: false, + path: '["object"]["some"]', + data: constants.TEST_REJSON_VALUE_3.object.some, // full value right now + type: 'string', + }, + }, + ].map(mainCheckFn); + }) + + describe('ACL', () => { + // todo: do not forget to remove rte.name check after fixing MEMORY USAGE issue in RedisJSON v2.0.0 + requirements('rte.acl', '!rte.name=MODS_PREVIEW'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + path: '.', + forceRetrieve: false, + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "json.get" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + path: '.', + forceRetrieve: true, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.get') + }, + { + name: 'Should throw error if no permissions for "json.get" command (another)', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + path: '.', + forceRetrieve: false, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.get') + }, + { + name: 'Should throw error if no permissions for "json.debug" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.', + forceRetrieve: false, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.debug') + }, + { + name: 'Should throw error if no permissions for "json.objkeys" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.', + forceRetrieve: false, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.objkeys') + }, + { + name: 'Should throw error if no permissions for "json.type" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.', + forceRetrieve: false, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.type') + }, + { + name: 'Should throw error if no permissions for "json.objlen" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.', + forceRetrieve: false, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.objlen') + }, + { + name: 'Should throw error if no permissions for "json.arrlen" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_REJSON_KEY_3, + path: '.', + forceRetrieve: false, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.arrlen') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/rejson-rl/POST-instance-id-rejson_rl.test.ts b/redisinsight/api/test/api/rejson-rl/POST-instance-id-rejson_rl.test.ts new file mode 100644 index 0000000000..7b7cad616d --- /dev/null +++ b/redisinsight/api/test/api/rejson-rl/POST-instance-id-rejson_rl.test.ts @@ -0,0 +1,196 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/rejson-rl`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + data: Joi.string().required().messages({ + 'any.required': '{#label} should be a correct serialized json string', + }), + expire: Joi.number().integer().allow(null).min(1).max(2147483647), +}).strict(); + +const validInputData = { + keyName: constants.TEST_SET_KEY_1, + data: JSON.stringify(constants.TEST_REJSON_VALUE_1), + expire: constants.TEST_SET_EXPIRE_1, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(0); + } + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(1); + expect(await rte.data.executeCommand('json.get', testCase.data.keyName, '.')) + .to.deep.eql(testCase.data.data); + expect(await rte.client.ttl(testCase.data.keyName)).to.eql(testCase.data.expire || -1); + } + } + }); +}; + +describe('POST /instance/:instanceId/rejson-rl', () => { + requirements('rte.modules.rejson'); + + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should create item with empty value', + data: { + keyName: constants.getRandomString(), + data: JSON.stringify(''), + }, + statusCode: 201, + }, + { + name: 'Should create item with null', + data: { + keyName: constants.getRandomString(), + data: JSON.stringify(null), + }, + statusCode: 201, + }, + { + name: 'Should create item with boolean', + data: { + keyName: constants.getRandomString(), + data: JSON.stringify(true), + }, + statusCode: 201, + }, + { + name: 'Should create item with array', + data: { + keyName: constants.getRandomString(), + data: JSON.stringify([1 ,2 ,3, 'somestring']), + }, + statusCode: 201, + }, + { + name: 'Should create item with object', + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify(constants.TEST_REJSON_VALUE_1), + }, + statusCode: 201, + }, + { + name: 'Should create item with key ttl', + data: { + keyName: constants.getRandomString(), + data: JSON.stringify(constants.getRandomString()), + expire: constants.TEST_REJSON_EXPIRE_1, + }, + statusCode: 201, + }, + { + name: 'Should return conflict error if key already exists', + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify(constants.getRandomString()), + }, + statusCode: 409, + responseBody: { + statusCode: 409, + error: 'Conflict', + message: 'This key name is already in use.', + }, + after: async () => { + // check that value was not overwritten + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_1, '.')) + .to.deep.eql(JSON.stringify(constants.TEST_REJSON_VALUE_1)); + } + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_REJSON_KEY_1, + data: JSON.stringify(constants.getRandomString()), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => { + // check that value was not overwritten + expect(await rte.data.executeCommand('json.get', constants.TEST_REJSON_KEY_1, '.')) + .to.deep.eql(JSON.stringify(constants.TEST_REJSON_VALUE_1)); + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + data: JSON.stringify(constants.getRandomString()), + }, + statusCode: 201, + }, + { + name: 'Should throw error if no permissions for "json.set" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + data: JSON.stringify(constants.getRandomString()), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -json.set') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/reporters.json b/redisinsight/api/test/api/reporters.json new file mode 100644 index 0000000000..e7ee7aea0f --- /dev/null +++ b/redisinsight/api/test/api/reporters.json @@ -0,0 +1,9 @@ +{ + "reporterEnabled": "spec,@mochajs/json-file-reporter,mocha-junit-reporter", + "mochajsJsonFileReporterReporterOptions": { + "output": "coverage/test-run-result.json" + }, + "mochaJunitReporterReporterOptions": { + "mochaFile": "coverage/test-run-result.xml" + } +} diff --git a/redisinsight/api/test/api/set/DELETE-instance-id-set-members.test.ts b/redisinsight/api/test/api/set/DELETE-instance-id-set-members.test.ts new file mode 100644 index 0000000000..453498271b --- /dev/null +++ b/redisinsight/api/test/api/set/DELETE-instance-id-set-members.test.ts @@ -0,0 +1,197 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).delete(`/instance/${instanceId}/set/members`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + members: Joi.array().items(Joi.any()).required(), // todo: look at BE validation rules for string members +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], +}; + +const responseSchema = Joi.object().keys({ + affected: Joi.number().integer().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('DELETE /instance/:instanceId/set/members', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + before(async () => await rte.data.generateKeys(true)); + + [ + { + name: 'Should delete single member', + data: { + keyName: constants.TEST_SET_KEY_2, + members: ['member_1'], + }, + responseSchema, + responseBody: { + affected: 1 + }, + after: async () => { + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_2, 0, 'count', 1000); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1].length).to.eql(99); + }, + }, + { + name: 'Should delete multiple members', + data: { + keyName: constants.TEST_SET_KEY_2, + members: ['member_2', 'member_3', 'member_4'], + }, + responseSchema, + responseBody: { + affected: 3 + }, + after: async () => { + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_2, 0, 'count', 1000); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1].length).to.eql(96); + }, + }, + { + name: 'Should not delete any member if incorrect member passed', + data: { + keyName: constants.TEST_SET_KEY_2, + members: [constants.getRandomString()], + }, + responseSchema, + responseBody: { + affected: 0 + }, + after: async () => { + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_2, 0, 'count', 1000); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1].length).to.eql(96); + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_2, + members: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => { + // check that value was not overwritten + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_1, 0, 'count', 100); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1]).to.eql([constants.TEST_SET_MEMBER_1]); + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should delete member', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + members: [constants.getRandomString()], + }, + responseSchema, + responseBody: { + affected: 0, + } + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + members: [constants.getRandomString()], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "srem" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + members: [constants.getRandomString()], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -srem') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/set/POST-instance-id-set-get_members.test.ts b/redisinsight/api/test/api/set/POST-instance-id-set-get_members.test.ts new file mode 100644 index 0000000000..dd580c7c29 --- /dev/null +++ b/redisinsight/api/test/api/set/POST-instance-id-set-get_members.test.ts @@ -0,0 +1,283 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/set/get-members`); + +// input data schema // todo: review BE for transform true -> 1 +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + cursor: Joi.number().integer().min(0).allow(true).required().messages({ + 'any.required': 'cursor should not be empty' + }), + count: Joi.number().integer().min(1).allow(true, null).messages({ + 'any.required': 'count should not be empty' + }), + match: Joi.string().allow(null), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + cursor: 0, + count: 1, + match: constants.getRandomString(), +}; + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), + total: Joi.number().integer().required(), + members: Joi.array().items(Joi.string()), + nextCursor: Joi.number().integer().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/set/get-members', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + before(async () => await rte.data.generateKeys(true)); + + [ + { + name: 'Should find by exact match', + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + count: 15, + match: 'member_9' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_SET_KEY_2); + expect(body.total).to.eql(100); + expect(body.members.length).to.eql(1); + } + }, + { + name: 'Should not find any member', + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + count: 15, + match: 'notExistin*' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_SET_KEY_2); + expect(body.total).to.eql(100); + expect(body.members.length).to.eql(0); + } + }, + { + name: 'Should query 15 members', + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + count: 15, + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_SET_KEY_2); + expect(body.total).to.eql(100); + expect(body.members.length).to.gte(15); + expect(body.members.length).to.lt(100); + } + }, + { + name: 'Should query by * in the end', + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + count: 15, + match: 'member_9*' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_SET_KEY_2); + expect(body.total).to.eql(100); + expect(body.members.length).to.eql(11); + } + }, + { + name: 'Should query by * in the beginning', + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + count: 15, + match: '*ber_9' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_SET_KEY_2); + expect(body.total).to.eql(100); + expect(body.members.length).to.eql(1); + } + }, + { + name: 'Should query by * in the middle', + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + count: 15, + match: 'membe*_9' + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_SET_KEY_2); + expect(body.total).to.eql(100); + expect(body.members.length).to.eql(1); + } + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + cursor: 0, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_2, + members: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => { + // check that value was not overwritten + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_1, 0, 'count', 100); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1]).to.eql([constants.TEST_SET_MEMBER_1]); + }, + }, + ].map(mainCheckFn); + + it('Should scan entire set', async () => { + const members = []; + let cursor = null; + + while (cursor !== 0) { + await validateApiCall({ + endpoint, + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: cursor || 0, + }, + checkFn: ({ body }) => { + cursor = body.nextCursor; + members.push(...body.members); + }, + }); + } + + expect(members.length).to.be.gte(100); + expect(cursor).to.eql(0); + }); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should add member', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + }, + }, + { + name: 'Should throw error if no permissions for "scard" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -scard') + }, + { + name: 'Should throw error if no permissions for "sismember" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + match: 'asd', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -sismember') + }, + { + name: 'Should throw error if no permissions for "sscan" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + cursor: 0, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -sscan') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/set/POST-instance-id-set.test.ts b/redisinsight/api/test/api/set/POST-instance-id-set.test.ts new file mode 100644 index 0000000000..1782239572 --- /dev/null +++ b/redisinsight/api/test/api/set/POST-instance-id-set.test.ts @@ -0,0 +1,187 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/set`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + members: Joi.array().items(Joi.string().allow(null)).required().messages({ + 'string.base': 'each value in members must be a string', + }), + expire: Joi.number().integer().allow(null).min(1).max(2147483647), +}).strict(); + +const validInputData = { + keyName: constants.TEST_SET_KEY_1, + members: [constants.TEST_SET_MEMBER_1], + expire: constants.TEST_SET_EXPIRE_1, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(0); + } + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(1); + const scanResult = await rte.client.sscan(testCase.data.keyName, 0, 'count', 100); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1]).to.eql(testCase.data.members); + expect(await rte.client.ttl(testCase.data.keyName)).to.eql(testCase.data.expire || -1); + } + } + }); +}; + +describe('POST /instance/:instanceId/set', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should create item with empty value', + data: { + keyName: constants.getRandomString(), + members: [''], + }, + statusCode: 201, + }, + { + name: 'Should create item with key ttl', + data: { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], + expire: constants.TEST_SET_EXPIRE_1, + }, + statusCode: 201, + }, + { + name: 'Should create regular item', + data: { + keyName: constants.TEST_SET_KEY_1, + members: [constants.TEST_SET_MEMBER_1], + }, + statusCode: 201, + }, + { + name: 'Should return conflict error if key already exists', + data: { + keyName: constants.TEST_SET_KEY_1, + members: [constants.getRandomString()], + }, + statusCode: 409, + responseBody: { + statusCode: 409, + error: 'Conflict', + message: 'This key name is already in use.', + }, + after: async () => { + // check that value was not overwritten + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_1, 0, 'count', 100); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1]).to.eql([constants.TEST_SET_MEMBER_1]); + } + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + members: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => { + // check that value was not overwritten + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_1, 0, 'count', 100); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1]).to.eql([constants.TEST_SET_MEMBER_1]); + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], + }, + statusCode: 201, + }, + { + name: 'Should throw error if no permissions for "sadd" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -sadd') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/set/PUT-instance-id-set.test.ts b/redisinsight/api/test/api/set/PUT-instance-id-set.test.ts new file mode 100644 index 0000000000..7b6f8014db --- /dev/null +++ b/redisinsight/api/test/api/set/PUT-instance-id-set.test.ts @@ -0,0 +1,184 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).put(`/instance/${instanceId}/set`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + members: Joi.array().items(Joi.string()).required().messages({ + 'string.base': 'each value in members must be a string', + }), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PUT /instance/:instanceId/set', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + before(async () => await rte.data.generateKeys(true)); + + [ + { + name: 'Should not modify set as such member already exists', + data: { + keyName: constants.TEST_SET_KEY_2, + members: ['member_1'], + }, + after: async () => { + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_2, 0, 'count', 1000); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1].length).to.eql(100); + }, + }, + { + name: 'Should add single member', + data: { + keyName: constants.TEST_SET_KEY_2, + members: [constants.getRandomString()], + }, + after: async () => { + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_2, 0, 'count', 1000); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1].length).to.eql(101); + }, + }, + { + name: 'Should add multiple members', + data: { + keyName: constants.TEST_SET_KEY_2, + members: [ + constants.getRandomString(), + constants.getRandomString(), + constants.getRandomString(), + constants.getRandomString(), + ], + }, + after: async () => { + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_2, 0, 'count', 1000); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1].length).to.eql(105); + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_2, + members: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => { + // check that value was not overwritten + const scanResult = await rte.client.sscan(constants.TEST_SET_KEY_1, 0, 'count', 100); + expect(scanResult[0]).to.eql('0'); // full scan completed + expect(scanResult[1]).to.eql([constants.TEST_SET_MEMBER_1]); + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should add member', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + members: [constants.getRandomString()], + }, + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + members: [constants.getRandomString()], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + { + name: 'Should throw error if no permissions for "sadd" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_SET_KEY_2, + members: [constants.getRandomString()], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -sadd') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/settings/GET-settings-agreements-spec.test.ts b/redisinsight/api/test/api/settings/GET-settings-agreements-spec.test.ts new file mode 100644 index 0000000000..b536fd640b --- /dev/null +++ b/redisinsight/api/test/api/settings/GET-settings-agreements-spec.test.ts @@ -0,0 +1,59 @@ +import { + describe, + it, + deps, + Joi, + expect, + validateApiCall, +} from '../deps'; +import { constants } from '../../helpers/constants'; +const { server, request } = deps; + +// endpoint to test +const endpoint = () => request(server).get('/settings/agreements/spec'); + +const agreementItemSchema = Joi.object().keys({ + defaultValue: Joi.bool().required(), + required: Joi.bool().required(), + disabled: Joi.bool().required(), + displayInSetting: Joi.bool().required(), + editable: Joi.bool().required(), + since: Joi.string().required(), + title: Joi.string().required(), + label: Joi.string().required(), + description: Joi.string().optional(), + requiredText: Joi.string().optional(), +}); + +const responseSchema = Joi.object().keys({ + version: Joi.string().required(), + agreements: Joi.object().keys({ + eula: agreementItemSchema.required(), + analytics: agreementItemSchema.required(), + encryption: agreementItemSchema.required(), + }).pattern(/./, agreementItemSchema).required() +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('GET /settings/agreements/spec', () => { + [ + { + name: 'Should return valid JSON', + statusCode: 200, + responseSchema, + checkFn: ({ body }) => { + const encryptionAgreements = body.agreements.encryption; + expect(encryptionAgreements.since).to.eql('1.0.3'); + expect(encryptionAgreements.defaultValue).to.eql(constants.TEST_ENCRYPTION_STRATEGY === 'KEYTAR'); + } + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/settings/GET-settings.test.ts b/redisinsight/api/test/api/settings/GET-settings.test.ts new file mode 100644 index 0000000000..efba5297d1 --- /dev/null +++ b/redisinsight/api/test/api/settings/GET-settings.test.ts @@ -0,0 +1,70 @@ +import { + describe, + it, + deps, + Joi, + validateApiCall, + expect, + after, +} from '../deps'; +import { applyEulaAgreement, initSettings, resetSettings } from '../../helpers/local-db'; +const { server, request, constants } = deps; + +// endpoint to test +const endpoint = () => request(server).get('/settings'); + +const responseSchema = Joi.object().keys({ + theme: Joi.string().allow(null).required(), + scanThreshold: Joi.number().required(), + agreements: Joi.object().keys({ + version: Joi.string().required(), + eula: Joi.bool().required(), + encryption: Joi.bool(), + }).allow(null).required() +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('GET /settings', () => { + after(initSettings); + + [ + { + name: 'Should return default settings', + statusCode: 200, + responseSchema, + before: resetSettings, + checkFn: ({ body }) => { + expect(body).to.eql(constants.APP_DEFAULT_SETTINGS); + } + }, + { + name: 'Should return settings with applied EULA agreement', + statusCode: 200, + responseSchema, + before: applyEulaAgreement, + checkFn: ({ body }) => { + expect(body).to.have.nested.property("agreements.eula").that.deep.equals(true); + expect(body).to.have.nested.property("agreements.encryption").that.deep.equals(true); + }, + after: async () => initSettings(), + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/settings/PATCH-settings.test.ts b/redisinsight/api/test/api/settings/PATCH-settings.test.ts new file mode 100644 index 0000000000..1533e9ccf0 --- /dev/null +++ b/redisinsight/api/test/api/settings/PATCH-settings.test.ts @@ -0,0 +1,166 @@ +import * as AGREEMENTS_SPEC from 'src/constants/agreements-spec.json'; +import { + describe, + it, + deps, + Joi, + validateApiCall, + expect, + after, + before, + generateInvalidDataTestCases, + validateInvalidDataTestCase, +} from '../deps'; +import { initSettings, resetSettings } from '../../helpers/local-db'; +const { server, request, constants } = deps; + +// endpoint to test +const endpoint = () => request(server).patch('/settings'); + +const responseSchema = Joi.object().keys({ + theme: Joi.string().allow(null).required(), + scanThreshold: Joi.number().required(), + agreements: Joi.object().keys({ + version: Joi.string().required(), + eula: Joi.bool().required(), + encryption: Joi.bool(), + }).pattern(/./, Joi.boolean()).allow(null).required() +}).required(); + +// input data schema +const dataSchema = Joi.object({ + theme: Joi.string().allow(null).optional(), + scanThreshold: Joi.number().allow(null).min(500).optional(), + agreements: Joi.object().keys({ + eula: Joi.boolean().label('.eula').optional(), + encryption: Joi.boolean().label('.encryption').optional(), + }).allow(null).optional().messages({ + 'boolean.base': 'each value in agreements must be a boolean value', + 'object.base': 'agreements must be an instance of Map', + }), +}).strict(); + +const validInputData = { + theme: 'DARK', + scanThreshold: 100000, + agreements: { + eula: true, + analytics: false, + encryption: false, + }, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); +}; + +describe('PATCH /settings', () => { + after(resetSettings) + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('settings', () => { + before(resetSettings); + after(initSettings); + + return ([ + { + name: 'Should update only scanThreshold value', + statusCode: 200, + data: { scanThreshold: 10000000 }, + responseSchema, + checkFn: ({ body }) => { + expect(body).to.include({ + ...constants.APP_DEFAULT_SETTINGS, + scanThreshold: 10000000 + }); + }, + }, + { + name: 'Should update settings and agreements', + statusCode: 200, + data: validInputData, + responseSchema, + checkFn: ({ body }) => { + const { agreements, ...settings } = validInputData; + + expect(body).to.include(settings); + expect(body.agreements).to.include(agreements); + }, + }, + { + name: 'Should set default settings', + statusCode: 200, + data: { scanThreshold: null, theme: null }, + responseSchema, + checkFn: ({ body }) => { + const { agreements, ...defaultSettings } = constants.APP_DEFAULT_SETTINGS; + + expect(body).to.include(defaultSettings); + }, + }, + ].map(mainCheckFn)); + }); + + describe('agreements', () => { + before(resetSettings); + after(initSettings); + + const allAcceptedAgreements = {} + Object.keys(AGREEMENTS_SPEC.agreements).forEach(agreement => allAcceptedAgreements[agreement] = true); + return ([ + { + name: 'Should throw [Bad Request] if some agreements are missed in dto', + data: { + agreements: { + analytics: true, + }, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should accept all agreements defined in specification', + statusCode: 200, + data: { agreements: allAcceptedAgreements }, + responseSchema, + checkFn: ({ body }) => { + const { agreements, ...defaultSettings } = constants.APP_DEFAULT_SETTINGS; + + expect(body).to.include(defaultSettings); + expect(body.agreements).to.eql({ + version: AGREEMENTS_SPEC.version, + ...allAcceptedAgreements + }); + }, + }, + { + name: 'Should reject analytics agreement', + statusCode: 200, + data: { agreements: { analytics: false } }, + responseSchema, + checkFn: ({ body }) => { + const { agreements, ...defaultSettings } = constants.APP_DEFAULT_SETTINGS; + + expect(body).to.include(defaultSettings); + expect(body.agreements).to.eql({ + version: AGREEMENTS_SPEC.version, + ...allAcceptedAgreements, + analytics: false, + }); + }, + }, + ].map(mainCheckFn)); + }); +}); diff --git a/redisinsight/api/test/api/string/POST-instance-id-string.test.ts b/redisinsight/api/test/api/string/POST-instance-id-string.test.ts new file mode 100644 index 0000000000..65fb9eb4f7 --- /dev/null +++ b/redisinsight/api/test/api/string/POST-instance-id-string.test.ts @@ -0,0 +1,179 @@ +import { + expect, + describe, + it, + before, + Joi, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/string`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + value: Joi.string().required(), + expire: Joi.number().integer().allow(null).min(1).max(2147483647), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STRING_KEY_1, + value: constants.TEST_STRING_VALUE_1, + expire: constants.TEST_STRING_EXPIRE_1, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(0); + } + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(1); + expect(await rte.client.get(testCase.data.keyName)).to.eql(testCase.data.value); + expect(await rte.client.ttl(testCase.data.keyName)).to.eql(testCase.data.expire || -1); + } + } + }); +}; + +describe('POST /instance/:instanceId/string', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should create item with empty value', + data: { + keyName: constants.getRandomString(), + value: '', + }, + statusCode: 201, + }, + { + name: 'Should create item with key ttl', + data: { + keyName: constants.getRandomString(), + value: constants.getRandomString(), + expire: constants.TEST_STRING_EXPIRE_1, + }, + statusCode: 201, + }, + { + name: 'Should create regular item', + data: { + keyName: constants.TEST_STRING_KEY_1, + value: constants.TEST_STRING_VALUE_1, + }, + statusCode: 201, + }, + { + name: 'Should return conflict error if key already exists', + data: { + keyName: constants.TEST_STRING_KEY_1, + value: constants.getRandomString(), + }, + statusCode: 409, + responseBody: { + statusCode: 409, + error: 'Conflict', + message: 'This key name is already in use.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(constants.TEST_STRING_VALUE_1) + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + value: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(constants.TEST_STRING_VALUE_1) + }, + ].map(mainCheckFn); + }); + + describe('Big values', () => { + requirements('rte.onPremise'); + before(rte.data.truncate); + + [ + { + name: 'Should create 110MB string', + data: { + keyName: constants.TEST_STRING_KEY_1, + value: constants.GENERATE_BIG_TEST_STRING_VALUE(110), + }, + statusCode: 201, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + value: constants.TEST_STRING_VALUE_1, + }, + statusCode: 201, + }, + { + name: 'Should throw error if no permissions for "set" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + value: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -set') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/string/PUT-instance-id-string.test.ts b/redisinsight/api/test/api/string/PUT-instance-id-string.test.ts new file mode 100644 index 0000000000..7fec3131f1 --- /dev/null +++ b/redisinsight/api/test/api/string/PUT-instance-id-string.test.ts @@ -0,0 +1,184 @@ +import { + expect, + describe, + it, + before, + Joi, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).put(`/instance/${instanceId}/string`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + value: Joi.string().required(), +}).strict(); + +const validInputData = { + keyName: constants.TEST_STRING_KEY_1, + value: constants.TEST_STRING_VALUE_1, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test execution + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } else { + expect(await rte.client.get(testCase.data.keyName)).to.eql(testCase.data.value); + } + }); +}; + +describe('PUT /instance/:instanceId/string', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + value: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(constants.TEST_STRING_VALUE_1) + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + value: constants.getRandomString(), + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + }, + after: () => {} + }, + { + name: 'Should edit existing value', + data: { + keyName: constants.TEST_STRING_KEY_1, + value: '', + }, + statusCode: 200, + }, + { + name: 'Should edit existing value and do not edit ttl', + data: { + keyName: constants.TEST_STRING_KEY_2, + value: '' + }, + statusCode: 200, + after: async function () { + expect(await rte.client.get(constants.TEST_STRING_KEY_2)).to.eql(''); + expect(await rte.client.ttl(constants.TEST_STRING_KEY_2)).to.lte(constants.TEST_STRING_EXPIRE_2).gte(-1); + } + }, + { + name: 'Should edit existing value for different key type', + data: { + keyName: constants.TEST_HASH_KEY_1, + value: '', + }, + statusCode: 200, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + value: constants.TEST_STRING_VALUE_1, + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "set" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + value: '', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -set'), + after: async () => expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(constants.TEST_STRING_VALUE_1) + }, + { + name: 'Should throw error if no permissions for "ttl" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STRING_KEY_1, + value: '', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -ttl'), + after: async () => expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(constants.TEST_STRING_VALUE_1) + }, + { + name: 'Should throw error if no permissions for "expire" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_STRING_KEY_2, + value: '', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -expire'), + // todo: Implement transaction for set + expire commands on BE. As if no ACL rules for "expire" key will be edited but ttl will be not set + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/z-set/DELETE-instance-id-zSet-members.test.ts b/redisinsight/api/test/api/z-set/DELETE-instance-id-zSet-members.test.ts new file mode 100644 index 0000000000..f2713f72fc --- /dev/null +++ b/redisinsight/api/test/api/z-set/DELETE-instance-id-zSet-members.test.ts @@ -0,0 +1,192 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).delete(`/instance/${instanceId}/zSet/members`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + members: Joi.array().items(Joi.any()).required(), // todo: investigate BE validation rules +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], +}; + +const responseSchema = Joi.object().keys({ + affected: Joi.number().required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('DELETE /instance/:instanceId/zSet/members', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + members: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.zrange(constants.TEST_ZSET_KEY_1, 0, 10)) + .to.eql([ + constants.TEST_ZSET_MEMBER_1, + constants.TEST_ZSET_MEMBER_2, + ]) + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + members: [constants.getRandomString()], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return BadRequest error if try to modify incorrect data type', + data: { + keyName: constants.TEST_STRING_KEY_1, + members: [constants.getRandomString()], + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should remove single member', + data: { + keyName: constants.TEST_ZSET_KEY_2, + members: ['member_1'], + }, + responseSchema, + responseBody: { + affected: 1, + }, + after: async () => { + const members = await rte.client.zrange(constants.TEST_ZSET_KEY_2, 0, 1000); + expect(members.length).to.eql(99); + } + }, + { + name: 'Should remove multiple member', + data: { + keyName: constants.TEST_ZSET_KEY_2, + members: ['member_2', 'member_3', 'member_4', 'member_100'], + }, + responseSchema, + responseBody: { + affected: 4, + }, + after: async () => { + const members = await rte.client.zrange(constants.TEST_ZSET_KEY_2, 0, 1000); + expect(members.length).to.eql(95); + } + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should remove all members and key', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + members: [constants.TEST_ZSET_MEMBER_1, constants.TEST_ZSET_MEMBER_2], + }, + responseBody: { + affected: 2, + }, + after: async () => { + expect(await rte.client.exists(constants.TEST_ZSET_KEY_1)).to.eql(0); + }, + }, + { + name: 'Should throw error if no permissions for "zrem" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_2, + members: [constants.getRandomString()], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zrem') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_2, + members: [constants.getRandomString()], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/z-set/PATCH-instance-id-zSet.test.ts b/redisinsight/api/test/api/z-set/PATCH-instance-id-zSet.test.ts new file mode 100644 index 0000000000..faa06af33d --- /dev/null +++ b/redisinsight/api/test/api/z-set/PATCH-instance-id-zSet.test.ts @@ -0,0 +1,197 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).patch(`/instance/${instanceId}/zSet`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + member: Joi.object().keys({ + name: Joi.string().required(), + // todo: allow(true) - is incorrect but will be transformed to number by BE. Investigate/fix it + score: Joi.number().required().allow(true), + }).messages({ + 'number.base': '{#lavel} must be a number', + }), +}).strict(); + +const validInputData = { + keyName: constants.TEST_ZSET_KEY_1, + member: { + name: constants.TEST_ZSET_MEMBER_1, + score: constants.TEST_ZSET_MEMBER_1_SCORE, + }, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PATCH /instance/:instanceId/zSet', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + member: { + name: constants.getRandomString(), + score: 0 + }, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.zrange(constants.TEST_ZSET_KEY_1, 0, 10)) + .to.eql([ + constants.TEST_ZSET_MEMBER_1, + constants.TEST_ZSET_MEMBER_2, + ]) + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + member: { + name: constants.getRandomString(), + score: 0 + }, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if try to modify incorrect data type', + data: { + keyName: constants.TEST_STRING_KEY_1, + member: { + name: constants.getRandomString(), + score: 0 + }, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should modify member with empty value', + data: { + keyName: constants.TEST_ZSET_KEY_1, + member: { + name: constants.TEST_ZSET_MEMBER_1, + score: 1 + }, + }, + statusCode: 200, + after: async () => { + expect(await rte.client.zrange(constants.TEST_ZSET_KEY_1, 0, 10)).to.deep.eql([ + constants.TEST_ZSET_MEMBER_2, + constants.TEST_ZSET_MEMBER_1, + ]); + } + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + member: { + name: constants.TEST_ZSET_MEMBER_1, + score: 0.1 + }, + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "zadd" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + member: { + name: constants.getRandomString(), + score: 0 + }, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zadd') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + member: { + name: constants.getRandomString(), + score: 0 + }, + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/z-set/POST-instance-id-zSet-get_members.test.ts b/redisinsight/api/test/api/z-set/POST-instance-id-zSet-get_members.test.ts new file mode 100644 index 0000000000..19c1602f42 --- /dev/null +++ b/redisinsight/api/test/api/z-set/POST-instance-id-zSet-get_members.test.ts @@ -0,0 +1,237 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/zSet/get-members`); + +// input data schema todo: investigate BE validation +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + offset: Joi.number().integer().min(0).allow(true).required().messages({ + 'any.required': '{#label} should not be empty', + }), + count: Joi.number().integer().min(1).allow(true).required().messages({ + 'any.required': '{#label} should not be empty', + }), + sortOrder: Joi.string().valid('DESC', 'ASC'), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + offset: 0, + count: 15, + sortOrder: 'DESC', +}; + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), + total: Joi.number().integer().required(), + members: Joi.array().items(Joi.object().keys({ + name: Joi.string().required(), + score: Joi.number().required(), + })).required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/zSet/get-members', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should query 15 members sorted DESC', + data: { + keyName: constants.TEST_ZSET_KEY_2, + offset: 0, + count: 15, + sortOrder: 'DESC', + }, + responseSchema, + responseBody: { + keyName: constants.TEST_ZSET_KEY_2, + total: 100, + members: (new Array(15).fill(0)).map((item, i) => { + return { + name: `member_${100 - i}`, + score: 100 - i, + }; + }), + }, + }, + { + name: 'Should query 45 members sorted ASC', + data: { + keyName: constants.TEST_ZSET_KEY_2, + offset: 0, + count: 45, + sortOrder: 'ASC', + }, + responseSchema, + responseBody: { + keyName: constants.TEST_ZSET_KEY_2, + total: 100, + members: (new Array(45).fill(0)).map((item, i) => { + return { + name: `member_${i + 1}`, + score: i + 1, + }; + }), + }, + }, + { + name: 'Should query next 45 members sorted ASC', + data: { + keyName: constants.TEST_ZSET_KEY_2, + offset: 45, + count: 45, + sortOrder: 'ASC', + }, + responseSchema, + responseBody: { + keyName: constants.TEST_ZSET_KEY_2, + total: 100, + members: (new Array(45).fill(0)).map((item, i) => { + return { + name: `member_${i + 45 + 1}`, + score: i + 45 + 1, + }; + }), + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + offset: 45, + count: 45, + sortOrder: 'ASC', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + offset: 45, + count: 45, + sortOrder: 'ASC', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should remove all members and key', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + offset: 0, + count: 15, + sortOrder: 'ASC', + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "zcard" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_2, + offset: 0, + count: 15, + sortOrder: 'ASC', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zcard') + }, + { + name: 'Should throw error if no permissions for "zrange" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_2, + offset: 0, + count: 15, + sortOrder: 'ASC', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zrange') + }, + { + name: 'Should throw error if no permissions for "zrevrange" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_2, + offset: 0, + count: 15, + sortOrder: 'DESC', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zrevrange') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/z-set/POST-instance-id-zSet-search.test.ts b/redisinsight/api/test/api/z-set/POST-instance-id-zSet-search.test.ts new file mode 100644 index 0000000000..5a504a0041 --- /dev/null +++ b/redisinsight/api/test/api/z-set/POST-instance-id-zSet-search.test.ts @@ -0,0 +1,275 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/zSet/search`); + +// input data schema todo: investigate BE validation +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + cursor: Joi.number().integer().min(0).allow(true).required().messages({ + 'any.required': '{#label} should not be empty', + }), + count: Joi.number().integer().min(1).allow(true, null).messages({ + 'any.required': '{#label} should not be empty', + }), + match: Joi.string().required(), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + cursor: 0, + count: 15, + match: '*', +}; + +const responseSchema = Joi.object().keys({ + keyName: Joi.string().required(), + total: Joi.number().integer().required(), + nextCursor: Joi.number().integer().required(), + members: Joi.array().items(Joi.object().keys({ + name: Joi.string().required(), + score: Joi.number().required(), + })).required(), +}).required(); + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('POST /instance/:instanceId/zSet/search', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should find by exact match', + data: { + keyName: constants.TEST_ZSET_KEY_3, + cursor: 0, + count: 15, + match: 'member_2555', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_ZSET_KEY_3); + expect(body.total).to.eql(3000); + expect(body.members.length).to.eq(1); + expect(body.members[0].name).to.eq('member_2555'); + expect(body.members[0].score).to.eq("2555"); // todo: check score type on BE!!! + }, + }, + { + name: 'Should not find any member', + data: { + keyName: constants.TEST_ZSET_KEY_3, + cursor: 0, + count: 15, + match: 'notExis*', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_ZSET_KEY_3); + expect(body.total).to.eql(3000); + expect(body.members.length).to.eq(0); + }, + }, + { + name: 'Should query 15 members', + data: { + keyName: constants.TEST_ZSET_KEY_3, + cursor: 0, + count: 15, + match: '*', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_ZSET_KEY_3); + expect(body.total).to.eql(3000); + expect(body.members.length).to.gte(15); + expect(body.members.length).to.lt(3000); + }, + }, + { + name: 'Should query members with * in the end', + data: { + keyName: constants.TEST_ZSET_KEY_3, + cursor: 0, + count: 15, + match: 'member_215*', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_ZSET_KEY_3); + expect(body.total).to.eql(3000); + expect(body.members.length).to.eq(11); + }, + }, + { + name: 'Should query members with * in the beginning', + data: { + keyName: constants.TEST_ZSET_KEY_3, + cursor: 0, + count: 15, + match: '*r_2155', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_ZSET_KEY_3); + expect(body.total).to.eql(3000); + expect(body.members.length).to.eq(1); + expect(body.members[0].name).to.eq('member_2155'); + expect(body.members[0].score).to.eq(2155); + }, + }, + { + name: 'Should query members with * in the middle', + data: { + keyName: constants.TEST_ZSET_KEY_3, + cursor: 0, + count: 15, + match: 'mem*r_2155', + }, + responseSchema, + checkFn: ({ body }) => { + expect(body.keyName).to.eql(constants.TEST_ZSET_KEY_3); + expect(body.total).to.eql(3000); + expect(body.members.length).to.eq(1); + expect(body.members[0].name).to.eq('member_2155'); + expect(body.members[0].score).to.eq(2155); + }, + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + cursor: 0, + count: 15, + match: '*', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + cursor: 0, + count: 15, + match: '*', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should remove all members and key', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + cursor: 0, + count: 15, + match: '*', + }, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "zcard" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_2, + cursor: 0, + count: 15, + match: '*', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zcard') + }, + { + name: 'Should throw error if no permissions for "zscan" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_2, + cursor: 0, + count: 15, + match: '*', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zscan') + }, + { + name: 'Should throw error if no permissions for "zscore" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_2, + cursor: 0, + count: 15, + match: 'member_1', + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zscore') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/z-set/POST-instance-id-zSet.test.ts b/redisinsight/api/test/api/z-set/POST-instance-id-zSet.test.ts new file mode 100644 index 0000000000..4878d72fe5 --- /dev/null +++ b/redisinsight/api/test/api/z-set/POST-instance-id-zSet.test.ts @@ -0,0 +1,214 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/zSet`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + members: Joi.array().items(Joi.object().keys({ + name: Joi.string().required().label('.name'), + // todo: allow(true) - is incorrect but will be transformed to number by BE. Investigate/fix it + score: Joi.number().required().allow(true).label('.score'), + })).messages({ + 'number.base': '{#lavel} must be a number', + 'array.sparse': 'members must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), + expire: Joi.number().integer().allow(null).min(1).max(2147483647), +}).strict(); + +const validInputData = { + keyName: constants.TEST_ZSET_KEY_1, + members: [{ + name: constants.TEST_ZSET_MEMBER_1, + score: constants.TEST_ZSET_MEMBER_1_SCORE, + }], + expire: constants.TEST_ZSET_EXPIRE_1, +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(0); + } + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } else { + if (testCase.statusCode === 201) { + expect(await rte.client.exists(testCase.data.keyName)).to.eql(1); + expect(await rte.client.zrange(testCase.data.keyName, 0, 10)).to.eql([testCase.data.members[0].name]); + expect(await rte.client.ttl(testCase.data.keyName)).to.eql(testCase.data.expire || -1); + } + } + }); +}; + +describe('POST /instance/:instanceId/zSet', () => { + before(rte.data.truncate); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should create item with empty value', + data: { + keyName: constants.getRandomString(), + members: [{ + name: '', + score: 0 + }], + }, + statusCode: 201, + }, + { + name: 'Should create item with key ttl', + data: { + keyName: constants.getRandomString(), + members: [{ + name: constants.getRandomString(), + score: 0 + }], + expire: constants.TEST_ZSET_EXPIRE_1, + }, + statusCode: 201, + }, + { + name: 'Should create regular item', + data: { + keyName: constants.TEST_ZSET_KEY_1, + members: [{ + name: constants.TEST_ZSET_MEMBER_1, + score: 0 + }], + }, + statusCode: 201, + }, + { + name: 'Should return conflict error if key already exists', + data: { + keyName: constants.TEST_ZSET_KEY_1, + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 409, + responseBody: { + statusCode: 409, + error: 'Conflict', + message: 'This key name is already in use.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.zrange(constants.TEST_ZSET_KEY_1, 0, 10)) + .to.eql([constants.TEST_ZSET_MEMBER_1]) + }, + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_LIST_KEY_1, + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.zrange(constants.TEST_ZSET_KEY_1, 0, 10)) + .to.eql([constants.TEST_ZSET_MEMBER_1]) + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 201, + }, + { + name: 'Should throw error if no permissions for "zadd" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zadd') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/z-set/PUT-instance-id-zSet.test.ts b/redisinsight/api/test/api/z-set/PUT-instance-id-zSet.test.ts new file mode 100644 index 0000000000..411bf07414 --- /dev/null +++ b/redisinsight/api/test/api/z-set/PUT-instance-id-zSet.test.ts @@ -0,0 +1,223 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).put(`/instance/${instanceId}/zSet`); + +// input data schema +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + members: Joi.array().items(Joi.object().keys({ + name: Joi.string().required().label('.name'), + // todo: allow(true) - is incorrect but will be transformed to number by BE. Investigate/fix it + score: Joi.number().required().allow(true).label('.score'), + })).messages({ + 'number.base': '{#lavel} must be a number', + 'array.sparse': 'members must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), +}).strict(); + +const validInputData = { + keyName: constants.TEST_ZSET_KEY_1, + members: [{ + name: constants.TEST_ZSET_MEMBER_1, + score: constants.TEST_ZSET_MEMBER_1_SCORE, + }], +}; + +const mainCheckFn = async (testCase) => { + it(testCase.name, async () => { + // additional checks before test run + if (testCase.before) { + await testCase.before(); + } + + await validateApiCall({ + endpoint, + ...testCase, + }); + + // additional checks after test pass + if (testCase.after) { + await testCase.after(); + } + }); +}; + +describe('PUT /instance/:instanceId/zSet', () => { + before(async () => await rte.data.generateKeys(true)); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should return NotFound error if instance id does not exists', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + after: async () => + // check that value was not overwritten + expect(await rte.client.zrange(constants.TEST_ZSET_KEY_1, 0, 10)) + .to.eql([ + constants.TEST_ZSET_MEMBER_1, + constants.TEST_ZSET_MEMBER_2, + ]) + }, + { + name: 'Should return NotFound error if key does not exists', + data: { + keyName: constants.getRandomString(), + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return BadRequest error if try to modify incorrect data type', + data: { + keyName: constants.TEST_STRING_KEY_1, + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + }, + { + name: 'Should add member with empty value', + data: { + keyName: constants.TEST_ZSET_KEY_1, + members: [{ + name: '', + score: 1 + }], + }, + statusCode: 200, + after: async () => { + expect(await rte.client.zrange(constants.TEST_ZSET_KEY_1, 0, 10)).to.deep.eql([ + constants.TEST_ZSET_MEMBER_1, + constants.TEST_ZSET_MEMBER_2, + '', + ]); + } + }, + { + name: 'Should add few members', + data: { + keyName: constants.TEST_ZSET_KEY_1, + members: [{ + name: '2', + score: 2 + }, { + name: '3', + score: 3 + }], + }, + statusCode: 200, + after: async () => { + expect(await rte.client.zrange(constants.TEST_ZSET_KEY_1, 0, 10)).to.deep.eql([ + constants.TEST_ZSET_MEMBER_1, + constants.TEST_ZSET_MEMBER_2, + '', + '2', + '3', + ]); + } + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular item', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 200, + }, + { + name: 'Should throw error if no permissions for "zadd" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.TEST_ZSET_KEY_1, + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -zadd') + }, + { + name: 'Should throw error if no permissions for "exists" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + keyName: constants.getRandomString(), + members: [{ + name: constants.getRandomString(), + score: 0 + }], + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -exists') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/helpers/cloud.ts b/redisinsight/api/test/helpers/cloud.ts new file mode 100644 index 0000000000..78e58ea01f --- /dev/null +++ b/redisinsight/api/test/helpers/cloud.ts @@ -0,0 +1,155 @@ +import { constants } from './constants'; +import * as request from 'supertest'; +import * as _ from 'lodash'; + +export const initCloudDatabase = async () => { + let subscription = await getSubscriptionByName(constants.TEST_CLOUD_SUBSCRIPTION_NAME); + let startTime; + let ttlThreshold; + + // create subscription with database + if (!subscription) { + const paymentMethodId = await getPaymentMethod(); + + if (!paymentMethodId) { + throw new Error('Cloud Account isn\'t configured well'); + } + + await createSubscription({ + name: constants.TEST_CLOUD_SUBSCRIPTION_NAME, + paymentMethodId: paymentMethodId, + cloudProviders: [ + { + regions: [ + { + region: 'us-east-1', + networking: { + deploymentCIDR: '10.0.0.0/24' + } + } + ] + } + ], + databases: [ + { + name: constants.TEST_CLOUD_DATABASE_NAME, + memoryLimitInGb: 1 + } + ] + } + ); + + ttlThreshold = 5 * 60 * 1000; // 5 min to wait for pending or active status + startTime = Date.now(); + while ((!subscription || !['pending', 'active'].includes(subscription.status)) && Date.now() - startTime < ttlThreshold) { + subscription = await new Promise((resolve, reject) => { + setTimeout(async () => { + const subscription = await getSubscriptionByName(constants.TEST_CLOUD_SUBSCRIPTION_NAME); + console.log(`Waiting for pending or active subscriptions ${(Date.now() - startTime) / 1000}s: `); + resolve(subscription); + }, +(Date.now() - startTime > 1000) * 20000); // execute each 20 sec + }); + } + } + + constants.TEST_CLOUD_SUBSCRIPTION_ID = subscription.id; + + switch (subscription.status) { + case 'pending': + ttlThreshold = 20 * 60 * 1000; // !!! 20 min to wait for active status + startTime = Date.now(); + while (subscription.status !== 'active' && Date.now() - startTime < ttlThreshold) { + subscription = await new Promise((resolve, reject) => { + setTimeout(async () => { + const subscription = await getSubscriptionByName(constants.TEST_CLOUD_SUBSCRIPTION_NAME); + console.log(`Waiting for active subscriptions ${(Date.now() - startTime) / 1000}s: `); + resolve(subscription); + }, +(Date.now() - startTime > 1000) * 20000); // execute each 20 sec + }); + } + if (subscription.status !== 'active') { + throw new Error('Timeout exceeded when waiting for subscription "active" status'); + } + case 'active': + let database = await getDatabaseByName(constants.TEST_CLOUD_SUBSCRIPTION_ID, constants.TEST_CLOUD_DATABASE_NAME); + + if (!database) { + throw new Error('Error when fetching database'); + } + + startTime = Date.now(); + ttlThreshold = 5 * 60 * 1000; // !!! 5 min to wait for database public endpoint + while (!database.publicEndpoint && Date.now() - startTime < ttlThreshold) { + database = await new Promise((resolve, reject) => { + setTimeout(async () => { + const database = await getDatabaseByName(constants.TEST_CLOUD_SUBSCRIPTION_ID, constants.TEST_CLOUD_DATABASE_NAME); + console.log(`Waiting for database public endpoint ${(Date.now() - startTime) / 1000}s: `); + resolve(database); + }, +(Date.now() - startTime > 1000) * 5000); // execute each 5 sec + }); + } + + const [host, port] = database.publicEndpoint.split(':') + constants.TEST_REDIS_HOST = host; + constants.TEST_REDIS_PORT = +port; + constants.TEST_REDIS_PASSWORD = database.security.password; + break; + default: + throw new Error(`Unexpected subscription status: ${subscription.status}`); + } +}; + +const getSubscriptionByName = async (name) => { + const { body } = await request(constants.TEST_CLOUD_API) + .get('/subscriptions') + .set('x-api-key', constants.TEST_CLOUD_API_KEY) + .set('x-api-secret-key', constants.TEST_CLOUD_API_SECRET_KEY) + .expect(200); + + return _.find(body.subscriptions, { name }); +}; + +const getDatabaseByName = async (subscriptionId, databaseName) => { + const { body } = await request(constants.TEST_CLOUD_API) + .get(`/subscriptions/${subscriptionId}/databases`) + .set('x-api-key', constants.TEST_CLOUD_API_KEY) + .set('x-api-secret-key', constants.TEST_CLOUD_API_SECRET_KEY) + .expect(200); + + const subscription = _.find(body.subscription, { subscriptionId }); + + if (!subscription) { + throw new Error(`There is no subscription with such id`); + } + + const database = _.find(subscription.databases, { name: databaseName }); + if (!database) { + throw new Error(`There is no database with name ${databaseName} in subscription ${subscriptionId}`); + } + const { body: fullDatabaseInfo } = await request(constants.TEST_CLOUD_API) + .get(`/subscriptions/${subscriptionId}/databases/${database.databaseId}`) + .set('x-api-key', constants.TEST_CLOUD_API_KEY) + .set('x-api-secret-key', constants.TEST_CLOUD_API_SECRET_KEY) + .expect(200); + + return fullDatabaseInfo; +}; + +const getPaymentMethod = async () => { + const { body } = await request(constants.TEST_CLOUD_API) + .get('/payment-methods') + .set('x-api-key', constants.TEST_CLOUD_API_KEY) + .set('x-api-secret-key', constants.TEST_CLOUD_API_SECRET_KEY) + .expect(200); + + return body.paymentMethods.length ? body.paymentMethods[0].id : null; +} + +const createSubscription = async (data) => { + return request(constants.TEST_CLOUD_API) + .post('/subscriptions') + .send(data) + .set('x-api-key', constants.TEST_CLOUD_API_KEY) + .set('x-api-secret-key', constants.TEST_CLOUD_API_SECRET_KEY) + .expect(202); +}; diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts new file mode 100644 index 0000000000..0ff8816356 --- /dev/null +++ b/redisinsight/api/test/helpers/constants.ts @@ -0,0 +1,185 @@ +import { v4 as uuidv4 } from 'uuid'; +import { randomBytes } from 'crypto'; + +const TEST_RUN_ID = uuidv4(); +const KEY_TTL = 100; +const CLUSTER_HASH_SLOT = '{slot1}'; +const APP_DEFAULT_SETTINGS = { + scanThreshold: 10000, + theme: null, + agreements: null, +} + +export const constants = { + // common + TEST_RUN_ID, + TEST_RUN_NAME: process.env.TEST_RUN_NAME || '', + KEY_TTL, + CLUSTER_HASH_SLOT, + getRandomString: () => TEST_RUN_ID + '_' + uuidv4(), + APP_DEFAULT_SETTINGS, + TEST_KEYTAR_PASSWORD: process.env.SECRET_STORAGE_PASSWORD || 'somepassword', + TEST_ENCRYPTION_STRATEGY: 'KEYTAR', + TEST_AGREEMENTS_VERSION: '1.0.3', + + // local database + TEST_LOCAL_DB_FILE_PATH: process.env.TEST_LOCAL_DB_FILE_PATH || './redisinsight.db', + TEST_NOT_EXISTED_INSTANCE_ID: uuidv4(), + TEST_INSTANCE_ID: uuidv4(), + TEST_INSTANCE_NAME: uuidv4(), + TEST_INSTANCE_ACL_ID: uuidv4(), + TEST_INSTANCE_ACL_NAME: uuidv4(), + TEST_INSTANCE_ACL_USER: uuidv4(), + TEST_INSTANCE_ACL_PASS: uuidv4(), + TEST_NEW_INSTANCE_NAME: uuidv4(), + TEST_CLI_UUID_1: uuidv4(), + TEST_INSTANCE_ID_2: uuidv4(), + TEST_INSTANCE_NAME_2: uuidv4(), + TEST_INSTANCE_HOST_2: uuidv4(), + TEST_INSTANCE_ID_3: uuidv4(), + TEST_INSTANCE_NAME_3: uuidv4(), + TEST_INSTANCE_HOST_3: uuidv4(), + + // redis client + TEST_REDIS_HOST: process.env.TEST_REDIS_HOST || 'localhost', + TEST_REDIS_PORT: parseInt(process.env.TEST_REDIS_PORT) || 6379, + TEST_REDIS_DB_INDEX: 7, + TEST_REDIS_USER: process.env.TEST_REDIS_USER, + TEST_REDIS_PASSWORD: process.env.TEST_REDIS_PASSWORD, + TEST_REDIS_TLS_CA: process.env.TEST_REDIS_TLS_CA, + TEST_USER_TLS_CERT: process.env.TEST_USER_TLS_CERT, + TEST_USER_TLS_KEY: process.env.TEST_USER_TLS_KEY, + + TEST_RTE_ON_PREMISE: process.env.TEST_RTE_ON_PREMISE ? process.env.TEST_RTE_ON_PREMISE === 'true' : true, + TEST_RTE_TYPE: process.env.TEST_RTE_DISCOVERY_TYPE || 'STANDALONE', + TEST_RTE_HOST: process.env.TEST_RTE_DISCOVERY_HOST, + TEST_RTE_PORT: process.env.TEST_RTE_DISCOVERY_PORT, + TEST_RTE_USER: process.env.TEST_RTE_DISCOVERY_USER, + TEST_RTE_PASSWORD: process.env.TEST_RTE_DISCOVERY_PASSWORD, + + // sentinel + TEST_SENTINEL_MASTER_GROUP: process.env.TEST_SENTINEL_MASTER_GROUP || 'primary1', + TEST_SENTINEL_MASTER_USER: process.env.TEST_SENTINEL_MASTER_USER, + TEST_SENTINEL_MASTER_PASS: process.env.TEST_SENTINEL_MASTER_PASS, + + // re + TEST_RE_HOST: process.env.TEST_RE_HOST || 'localhost', + TEST_RE_PORT: parseInt(process.env.TEST_RE_PORT) || 9443, + TEST_RE_USER: process.env.TEST_RE_USER, + TEST_RE_PASS: process.env.TEST_RE_PASS, + + // cloud + TEST_CLOUD_RTE: process.env.TEST_CLOUD_RTE, + TEST_CLOUD_API: process.env.REDIS_CLOUD_URL || process.env.TEST_CLOUD_API || 'https://qa-api.redislabs.com/v1', + TEST_CLOUD_API_KEY: process.env.TEST_CLOUD_API_KEY, + TEST_CLOUD_API_SECRET_KEY: process.env.TEST_CLOUD_API_SECRET_KEY, + TEST_CLOUD_SUBSCRIPTION_NAME: process.env.TEST_CLOUD_SUBSCRIPTION_NAME || 'ITests', + TEST_CLOUD_SUBSCRIPTION_ID: process.env.TEST_CLOUD_SUBSCRIPTION_ID, + TEST_CLOUD_DATABASE_NAME: process.env.TEST_CLOUD_DATABASE_NAME || 'ITests-db', + + STANDALONE: 'STANDALONE', + CLUSTER: 'CLUSTER', + SENTINEL: 'SENTINEL', + + // certificates + TEST_USER_CERT_ID: uuidv4(), + TEST_USER_CERT_NAME: uuidv4(), + TEST_USER_CERT_FILENAME: 'user.crt', + TEST_USER_CERT_KEY_FILENAME: 'user.key', + + TEST_CLIENT_CERT_NAME: 'client certificate', + TEST_CLIENT_CERT: '-----BEGIN CERTIFICATE-----\nMIIFJTCCAw0CFCnZUPMfcoAU/VJYA6Qf4cZIJp4iMA0GCSqGSIb3DQEBCwUAMEUx\nCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl\ncm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjEwNjIyMTcwMDQ2WhcNMzUwMzAxMTcw\nMDQ2WjBZMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE\nCgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQDDAlsb2NhbGhvc3Qw\nggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDfRqe8YhMbWSlcbpGcIxXf\ncxYt9IYa1oAhW/KJ7iHwjldy82ht6mdYIvhqSxo8Xo9AUpMl9LT3mZv1aCup8G4u\nS5DXdYNV5KuTTP8zx5pcw0GVKLKB7THOOFV8Fzyx8dQAA24Z7Bz9aRRAeQsm2+tN\nQHL6D71uVPt9D07Tu2GGjivFhT/gHn1VBFbrpGEF+Z5dQbh7fd1j3kBpEtSrMrTh\nQfYVWtpdRW6JvsdG/Y07fkFCWEHbgVGqnVjJEc38ieCImDFK6vR+Q1bFqtvkr1zw\nKx6X6Hol32LeI2TJ+cPtHak8L53cKJyoIe3xu/9uIqhqGL+GUqBGLNsGYVR9RgfF\nwndk3/2ZeRodxKKsjaIMBlLLgmZkXoO6+hmyE3RNZv1fmgrTbjs1XTlxpi+byVs1\nuqHFBKLt2NclAOIXf8IGt9+5cPSOenMEW3pUUUb8yXKUgBKfEU8HO38tbLDpY0hW\n3mS/hIiTzcr5kD1jgoJ17SKZXKgOd0dhilN263YZnpcy0zFTeLNyTBAopte84Mmh\nRoMFVM2r6449P7sbm1YvyUNTGwgwsFpr2eJNcKk8laW4uSelvSLxlm6e4VtQ0FEX\n+7igpL6Mxdu3BUqhJrcoeNzz4AvbwZWie9IbSaIz+FeB1lMXDAt1kis+QCNc1S6a\n4Ulsl2HAApT8u3Fdfs+c3QIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQClCb7TfzWz\nSMT/6Y9cB4phR/FFQxqumNaE1ER1hLvU1wiGX85KwpAQOpIOS3J8pDYmTIiD2Zl4\n/EoHr//OsfYQQ05LT7pR5qPHyz+pxp/OH35k+LIaPtG3E2PrkjHffG5udRhGAxtr\nP3pampp0NaoFDtVNSjj73jedxhXKQVIPypK34yGOa67ISbDOzWQEoHCUwFPsNIp0\nd3uq8WDb0Er9sX2pCheuHYtxs6jgNaXOmJT8VkAKwaKnpUejfFiA/YPtsGgkSlEX\nhv1G0jmMquhPrMBq/pQrqvnA69dPi009L8m+aO1q1w5HQhH67JPjYSVR8A+OWfJ+\n2oMRO6UP2Ryf3G4STL1sL7GF9rWWsKtQH8T4ESZ3WfHcw7NjuEt2ngH2bplAei/e\nxCVDWgNfAWYQqbJhqv4NMEaIh5L0GkTTCnjxsEq2ByFlka+hQqz+6PQW/gQYScgT\n2+D0DQW7RP1pvheLwYABLDkx1y0eXBOmcthxVn9GyjZOmZFreRIBhlHp2lN6wUiR\n9Qj7UvwJ7Jhocc2mNwNxEmFLRoKku+1uoc/n3b/chaq0WadlDohWmE7hiKA25AG+\nRj54Ou5G78qbWstZPR/sAXQGtUkgXuAkh+RcP8OcfpImUryZ/4cIoSzgAIE+NYX6\nif8fVyASWrgcBKk5RUyFCZKMkJceV9ZGFg==\n-----END CERTIFICATE-----\n', + TEST_CLIENT_KEY: '-----BEGIN RSA PRIVATE KEY-----\nMIIJKgIBAAKCAgEAuBRkoLY666J54zx2BFquG1drk+Scpw7/4VA/4wEF/RYN8vjU\n8jancCxK0lWWoIj+JdK2UXxJF9bjbmArnMyZm7EN1MKrbPeRIYCeH57ZWFTdMYHL\nZRBY2Frc7dYpe6+ow2Wyu1oGQVnu6fCGPDh9oYqD1ULC0KBzD/GtLGDoqxjiC+/y\nsW6T++XrZ4QGAfhewu7BXApMmE6q8EpJvB2g167IVdytaXIkIl5CWayS1Uva2wDH\nLcq+UkORlOEPH7cZej/du5+8vnpwpbIvBR+DJw9Y9q0sQSxyYsX36CW4fd+l3ClX\nDqD6MuiRQntpy8N73K1c3glALnBwWmQ9K3dQgDwcXL6mk9Pz5kJsnft3i2FMEyLb\nYx8j8dlem4CzFd1DT8p4WOVttg1iIQYdjggPAKUio1hevZB/BI2EwB0/U1IZ657W\n/krfwPoOPaWfC7i58RHcbKG14oHJSL0CzK4F+bfCSkvz1f1DrcMqkryU5in4x/1L\nG9r78eenuy96s9qhpKaeBKOLgREqZoLnqsWiqoVePb7bnSISW/VKGiXen9AlIWL4\nfOWJTs4PW0JLp9OLwxxbwVEkZkNsilWH/F0ueUZBZYYJVohh7tp2JABaeioxj+0V\ngwoFgQDbmpJvB/XkdG6Eg6J3cTnbHR7sOjFvSpCmUnjnhakZT5RRQVvYSvkCAwEA\nAQKCAgBRfRWe34ztyxtSMO29t7bje6uv6MBAZC96OuBNSaKxCxZZvTXnk7JDwhfN\nTP5FSt/XNpRnNjHVT9eWgRRNcXV+qr6ItTTWJDInNpzJOrTUmZzh0aeMsdPi0zaC\nQxBSJMz80wRwU8X5ICrXfRavigJzhLIfslIzsRO+tyoGP1BAjd9jkXFKgr0YAgxX\n4uYV8TFh8fe/GwAVXJ3nibtif2s4j7M3710FFPZSEJAmynKl4dKcqJeD+gCOwkKs\nOYVMcO3iZGtwJ6KSX/mGIH8YMX8Jx42GhdrVbyuj9idsqWYmst7lu5dCbpjT+Ih1\nedS30239nvFBia7T4AqcuUsq9sK3gbFJKGADWDEeJiRP66ITQRXMQDJ/F1UXwWWa\n0zOFF+UYAAf42JKDyhMjc9RPtRLBZJFsJ9wzhbyRl+UO23DTjZBs5OsOp6VVOhce\nSFVxG9tSLIVWSuZLKa0u+R7Bh5zUYaX2nYO3fNqueFoQdMm8EA31OiBVCPdVEvqf\nl/n0IaxN0G1zZMOmLoaUu0j1PwjkI8qZ4D1X/aDK1zYaMGAy8PdY0swtwRt/vC9F\nAfSwY4eSPgHEeyJH0cLJkUCrQCSNMLyCXhErMlesoNNdGAxFIho0OzsVVEZAt0zd\nP1NzsufcSKtsHEa+hM0YW80ST4GtyPkTKsRXtNDwMg3U4A+0AQKCAQEA2h+Wx7xN\n694m73VCoCkGTa08PRaZjIdztYpMpe7Lj8EjtrKVBYIHBj3JLmXtp1jXMhSsPWLr\nHrqQhy33/rX95JJBu5nxvok6wAISET3CEtxNUXe8GbN+QG6ry4TIcRH8aqqpFDOM\nb9V3ZFEbTOo6S4l9Thoqa+la2NK4WGbDufPVlI2unluEKVTHyak0Tf2Qu2SzIL8A\nqIpKB3MjweE9IGHtM0tgIdJjEQA8VS5Ai0pE8iohh5J2kIjEqPRMfwb9F+41tNlH\nuAkj2sZge2GFz3qYk8x5bKk37xH3ogCRQtxt8x5rlPZWpf6/LvEm04PtRPXuJZ0u\nm+voqrE8mVBtmQKCAQEA2AtuYZGKxEp+a/gde9TvkkCWiSnqfMwCQ+ax9TfGenS5\ntYh75uEpkcsIDE9d8GD4tkShyMbZPUglxR/WVsz4HsACXLoR/sRSWNuQkMhEltN0\n4VQcbaNkgKvktuvealueKEIWlKoKyqZEmmyJaW9wBt2mc9hku81kdXCAXTksjqgY\ncaayi9BYg5+EqKzmAIDA33sh+douv9VvJSPJ93g4f3UvOdUiAyFCbXnYN9Iq46nP\nuCc5qzoGHHi54o9FRHp0H0YioQxl5IvE42EkqJLnc1JaTixWUWhiFJjsbFlCcNsQ\n0aoIB/kVrOyXvnK+r/Jge/LILathqk7sjRjYaWdkYQKCAQEArYc4A0rxitY/j31w\nNc6tbxqEs+zI153jFegitlfVplX3PZ+xIqKhR/vbk4gPm3T4LqV3qZaKivXNiV2u\nz/qlNDSPCtqcEgNGs/5xtTm2rh6JfGiPQrsjk8r37X+Dn0C52XpP7Pxdm5Lt2ucT\nmws0uWd2Qq5aVWNenOR3OAz5ZXRw1DArXVxdNix2jR6JuAokHJEuWLzbnzn1Txvw\ntIumf56ogIhUwFOJ8LqJRRL40leRpj6SUjLZFH9aRTelq+E5dNJT875wah8LYT80\n/rNFKxzTSbIAX8v37cATi9R7u/91kVcAK5AWuxSBsKy1QMzR9Gzaux3jOLRjc3hx\nR19O8QKCAQEAyBKuAkVakTW7phl8lHU59+NAhX3/3drALkmyfDlO4ZC/etIOjF3w\ntUelCGFnyXjEW2drvBgKjqoF8GvvfysKjM+cYGsgxyLgb9HGK46LlnH1R8cxHIe4\nR0Do6k29CBoYeYfaiYp/u/QGjEv/ZVkCEhmqUJYRk6o+YlPxTGPqU6JwILAToU8s\n6ZgMrniP999EvrG1YUEhEh6Cc46VN0xqZf8L4S7z9JoUfnXcOrWzamqUJyKMUXnG\ntw9Gdf3gU+5jI6M75pEou2KEz13jKQoCtdWKM+LzfSiBzDlimWSAFyuIg+JG1bti\ny2W/kWuKFD8OAztvDnwsUiANCQ39PH+3gQKCAQEAqv5ig8A75OCtFwnOXadiI7xu\nOzZezpmgzwLxQTdLzkcoSZ6oSgpDs9123i6j2hzriIzp0DvoyYo9qC7KWSP4iP6b\nTi1gGJOADTehZ/DhLI7p6pCwi7YAWD/D6BhssmcKvdVDNjK1kqxJQetbI1XSEv2B\nnabfcN+yYd0T0HB0gEA8qrtxQF4lkpZNtAjUnPpMSzel9VKEisGm5UIAVTIk1Gbc\ndXQFkuq7T7DVQtYxkz9ZOqbZB0yMLKYpFXnUQ0z5OpYDgtp7Zs6r7CtTR2YROIQ0\nbFVfR3CPbk4Qj+QBZvIjoeiUJwZUab0JWRxn5BsoKAeHJ1BZtN7KsKMHiLPlgg==\n-----END RSA PRIVATE KEY-----\n', + + TEST_CA_ID: uuidv4(), + TEST_CA_NAME: 'ca certificate', + TEST_CA_FILENAME: 'redisCA.crt', + TEST_CA_CERT: '-----BEGIN CERTIFICATE-----\nMIIFazCCA1OgAwIBAgIUavmmz7/4r2muhE1He1u/6S1jLXEwDQYJKoZIhvcNAQEL\nBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\nGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMTA2MjIxNjMwNTJaFw00ODEx\nMDcxNjMwNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\nHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB\nAQUAA4ICDwAwggIKAoICAQDGUx5tjluzcotvXlr4XCqAbdO+ehD/djqzb3KxB4p4\nUNE5tqS4TykvbisASOnNj/643J0BSYKEZwNBjy/oAWmc3cVMq30WnWRENGRUKyr+\nqhgjR0OGMHxpAU8DiDgsJAuvvh86SU0xIo6PUWyO38XNIOGt05s61My9fW+Allai\n5/jj6knBej42cRY7B9hUgHfko9NXE5oUVFKE+dpH9IiMUGBm7SDi1ysB1vIMQhcT\n8ugQHdwXAiQfhDODNuDG48z6OprhGgHN5lYNFd3oFlweoFaqE0psFRh9bR5AuqES\nubxEFqMVwEjyJa8BgObRBwdHoipZt1FLDeKTP5/MGUm5n/2X+pcAi4Q7+9i+aVz5\ngFiCz6ndOFEj3X4CXcHHLVzI8ukQ3wQiDFXnomLOcFcuAJ9t+MisUOwts/Nvmqa0\n+copNgXu2N8K01G77HX1qbJ0uyF6pupw2EWW0yJXkoSeOeaFegHPMx6y3RUx1adl\nKu9vQ8JDodK4OwHfQcSBgj8aKA7huBnclgpBmM6B1czC6pw7DN6orLOlsx6cUusP\n4mELM2CNNYLUQuxhghTO8lAQTgvvth5MNSpxA6x/gKFGmLN9XUJIZweQQymeY137\n8elXS2yuoSyppisB+HDvp6MbegN1ldzhI0AjdUj9NDiiO5sDk+XscKA8tsZz/MgW\nMQIDAQABo1MwUTAdBgNVHQ4EFgQU0CzAfHYx+Tr/axoAsurYNR/t2RMwHwYDVR0j\nBBgwFoAU0CzAfHYx+Tr/axoAsurYNR/t2RMwDwYDVR0TAQH/BAUwAwEB/zANBgkq\nhkiG9w0BAQsFAAOCAgEAd6Fqt+Ji1DV/7XA6e5QeCjqhrPxsXaUorbNSy2a4U59y\nRj5lmI8RUPBt6AtSLWpeZ5JU2NQpK+4YfbopSPnVtc8Xipta1VmSr2grjT0n4cjY\nXkMHV4bwaHBhr1OI2REcBOiwNP2QzXK7uFa75nZUyQSC0C3Qi5EJri2+a6xMsuF5\nE8a9eyIvst1ESXJ9IJITc8e/eYFtpGw7WRClcm1UblwqYpO9sW9fFuZDpuBC0UH1\nGXolRnFYN8PstjxmXHtrjHGcmOY+t1yFnyxOgZ01rmaFt+JEFbPOmgN17wcAidrV\nAuXKWal9zrtlJc1J8GPHPpBTlZ+Qq5TlPI7Z3Boj9FCZdl3JEWUZGP7TPjxCWLoH\n2/wJppE7w2bQcnidQngZhf2PN5RNQASUa2QBae7rkztReJ6A/xMWXAOfgkj13IbS\nPIDZnBQYp5DKAxL9PRB/javL57/fUtYAxxzZK4xbvwY/lygv3+NetPqRHnx/IVBj\nuEal2rpdwyFcoJ3DODbh9eh6tWJB4wR8QyYm3ATF1VV+x6XX5u5t5Z4IUt8WJkgn\nHGzepJVYxzJMzjlyjqF1IG9e1da8c4DdRgmOn3R55G5BWQR3i6J+RAQY/O1S3VKA\n0FDYT/EDZRbtXWwStSWUIPxNZt62vNGgwzprQow9OfJHRuOzlzIiK2BqnixboOs=\n-----END CERTIFICATE-----\n', + + // Redis Strings + TEST_STRING_TYPE: 'string', + TEST_STRING_KEY_1: TEST_RUN_ID + '_string_1' + CLUSTER_HASH_SLOT, + TEST_STRING_VALUE_1: TEST_RUN_ID + '_value_1', + GENERATE_BIG_TEST_STRING_VALUE: (sizeInMB = 1) => randomBytes(sizeInMB * 1024 * 1024).toString(), + TEST_STRING_EXPIRE_1: KEY_TTL, + TEST_STRING_KEY_2: TEST_RUN_ID + '_string_2' + CLUSTER_HASH_SLOT, + TEST_STRING_VALUE_2: TEST_RUN_ID + '_value_2', + TEST_STRING_EXPIRE_2: KEY_TTL, + + // Redis List + TEST_LIST_TYPE: 'list', + TEST_LIST_KEY_1: TEST_RUN_ID + '_list_1' + CLUSTER_HASH_SLOT, + TEST_LIST_ELEMENT_1: TEST_RUN_ID + '_list_el_1', + TEST_LIST_ELEMENT_2: TEST_RUN_ID + '_list_el_2', + TEST_LIST_EXPIRE_1: KEY_TTL, + TEST_LIST_KEY_2: TEST_RUN_ID + '_list_2' + CLUSTER_HASH_SLOT, + + // Redis Set + TEST_SET_TYPE: 'set', + TEST_SET_KEY_1: TEST_RUN_ID + '_set_1' + CLUSTER_HASH_SLOT, + TEST_SET_MEMBER_1: TEST_RUN_ID + '_set_mem_1', + TEST_SET_MEMBER_2: TEST_RUN_ID + '_set_mem_2', + TEST_SET_EXPIRE_1: KEY_TTL, + TEST_SET_KEY_2: TEST_RUN_ID + '_set_2' + CLUSTER_HASH_SLOT, + + // Redis ZSet + TEST_ZSET_TYPE: 'zset', + TEST_ZSET_KEY_1: TEST_RUN_ID + '_zset_1' + CLUSTER_HASH_SLOT, + TEST_ZSET_MEMBER_1: TEST_RUN_ID + '_zset_mem_1', + TEST_ZSET_MEMBER_1_SCORE: 0, + TEST_ZSET_MEMBER_2: TEST_RUN_ID + '_zset_mem_2', + TEST_ZSET_MEMBER_2_SCORE: 0.1, + TEST_ZSET_EXPIRE_1: KEY_TTL, + TEST_ZSET_KEY_2: TEST_RUN_ID + '_zset_2' + CLUSTER_HASH_SLOT, + TEST_ZSET_KEY_3: TEST_RUN_ID + '_zset_3' + CLUSTER_HASH_SLOT, + + // Redis Hash + TEST_HASH_TYPE: 'hash', + TEST_HASH_KEY_1: TEST_RUN_ID + '_hash_1' + CLUSTER_HASH_SLOT, + TEST_HASH_FIELD_1_NAME: TEST_RUN_ID + '_hash_f_1_name', + TEST_HASH_FIELD_1_VALUE: TEST_RUN_ID + '_hash_f_1_val', + TEST_HASH_FIELD_2_NAME: TEST_RUN_ID + '_hash_f_2_name', + TEST_HASH_FIELD_2_VALUE: TEST_RUN_ID + '_hash_f_2_val', + TEST_HASH_EXPIRE_1: KEY_TTL, + TEST_HASH_KEY_2: TEST_RUN_ID + '_hash_2' + CLUSTER_HASH_SLOT, + + // Redis Stream + TEST_STREAM_TYPE: 'stream', + TEST_STREAM_KEY_1: TEST_RUN_ID + '_stream_1' + CLUSTER_HASH_SLOT, + TEST_STREAM_DATA_1: TEST_RUN_ID + '_stream_data_1', + TEST_STREAM_DATA_2: TEST_RUN_ID + '_stream_data_2', + + // ReJSON-RL + TEST_REJSON_TYPE: 'ReJSON-RL', + TEST_REJSON_KEY_1: TEST_RUN_ID + '_rejson_1' + CLUSTER_HASH_SLOT, + TEST_REJSON_VALUE_1: { test: 'value' }, + TEST_REJSON_EXPIRE_1: KEY_TTL, + TEST_REJSON_KEY_2: TEST_RUN_ID + '_rejson_2' + CLUSTER_HASH_SLOT, + TEST_REJSON_VALUE_2: [{ obj: 1 }], + TEST_REJSON_EXPIRE_2: KEY_TTL, + TEST_REJSON_KEY_3: TEST_RUN_ID + '_rejson_3' + CLUSTER_HASH_SLOT, + TEST_REJSON_VALUE_3: { array: [{ obj: 1 }, 2, 3], object: { some: randomBytes(1024).toString('hex'), field: 'value'} }, + TEST_REJSON_EXPIRE_3: KEY_TTL, + + // TSDB-TYPE + TEST_TS_TYPE: 'TSDB-TYPE', + TEST_TS_KEY_1: TEST_RUN_ID + '_ts_1' + CLUSTER_HASH_SLOT, + TEST_TS_TIMESTAMP_1: 1627537290803, + TEST_TS_VALUE_1: 10, + TEST_TS_TIMESTAMP_2: 1627537290804, + TEST_TS_VALUE_2: 20, + + // Graph + TEST_GRAPH_TYPE: 'graphdata', + TEST_GRAPH_KEY_1: TEST_RUN_ID + '_graph_1' + CLUSTER_HASH_SLOT, + TEST_GRAPH_NODE_1: TEST_RUN_ID + 'n1', + TEST_GRAPH_NODE_2: TEST_RUN_ID + 'n2', + + // RediSearch + TEST_SEARCH_HASH_INDEX_1: TEST_RUN_ID + '_hash_search_idx_1' + CLUSTER_HASH_SLOT, + TEST_SEARCH_HASH_KEY_PREFIX_1: TEST_RUN_ID + '_hash_search:', + TEST_SEARCH_JSON_INDEX_1: TEST_RUN_ID + '_json_search_idx_1' + CLUSTER_HASH_SLOT, + TEST_SEARCH_JSON_KEY_PREFIX_1: TEST_RUN_ID + '_json_search:', + + // etc... +} diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts new file mode 100644 index 0000000000..1714f0147b --- /dev/null +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -0,0 +1,279 @@ +import { get } from 'lodash'; +import { constants } from '../constants'; +import * as _ from 'lodash'; + +export const initDataHelper = (rte) => { + const client = rte.client; + + const executeCommand = async (...args: string[]): Promise => { + return client.nodes ? Promise.all(client.nodes('master').map(async (node) => { + try { + return node.send_command(...args); + } catch (e) { + return null; + } + })) : client.send_command(args.shift(), ...args); + }; + + const setAclUserRules = async ( + rules: string, + ): Promise => { + const command = `ACL SETUSER ${constants.TEST_INSTANCE_ACL_USER} reset on ${rules} >${constants.TEST_INSTANCE_ACL_PASS}`; + + return executeCommand(...command.split(' ')); + }; + + const truncate = async () => { + return client.nodes ? Promise.all(client.nodes('master').map(async (node) => { + try { + return node.flushall(); + } catch (e) { + return null; + } + })) : client.flushall(); + }; + + // keys + const generateKeys = async (clean: boolean) => { + if (clean) { + await truncate(); + } + + await generateStrings(); + await generateLists(); + await generateSets(); + await generateZSets(); + await generateHashes(); + await generateReJSONs(); + }; + + const insertKeysBasedOnEnv = async (pipeline, forcePipeline: boolean = false) => { + const builtInCommand = client.getBuiltinCommands().includes(pipeline[0][0]); + if (!forcePipeline && (!builtInCommand || rte.env.type === 'CLUSTER')) { + for (const command of pipeline) { + try { + await executeCommand(...command); // todo: implement performant way to insert keys for Cluster nodes + } catch (e) { + if (!e.message.includes('MOVED') && !e.message.includes('ASK')) { + throw e; + } + } + } + } else { + await client.pipeline(pipeline).exec(); + } + }; + + const generateAnyKeys = async (types: Array, number: number = 15000, clean: boolean) => { + if (clean) { + await truncate(); + } + + const numberPerType = Math.floor(number / types.length); + + for (let i = 0; i < types.length; i++) { + await insertKeysBasedOnEnv(types[i].create(numberPerType)); + } + } + + // Strings + const generateStrings = async (clean: boolean = false) => { + if (clean) { + await truncate(); + } + + await client.set(constants.TEST_STRING_KEY_1, constants.TEST_STRING_VALUE_1); + await client.set(constants.TEST_STRING_KEY_2, constants.TEST_STRING_VALUE_2, 'EX', constants.TEST_STRING_EXPIRE_2); + }; + + // List + const generateLists = async (clean: boolean = false) => { + if (clean) { + await truncate(); + } + + await client.lpush( + constants.TEST_LIST_KEY_1, + constants.TEST_LIST_ELEMENT_2, + constants.TEST_LIST_ELEMENT_1, + ); + await client.rpush( + constants.TEST_LIST_KEY_2, + ...(new Array(100).fill(0)).map((item, i) => `element_${i+1}`) + ); + }; + + // Set + const generateSets = async (clean: boolean = false) => { + if (clean) { + await truncate(); + } + + await client.sadd(constants.TEST_SET_KEY_1, constants.TEST_SET_MEMBER_1); + await client.sadd( + constants.TEST_SET_KEY_2, + ...(new Array(100).fill(0)).map((item, i) => `member_${i+1}`) + ); + }; + + // ZSet + const generateZSets = async (clean: boolean = false) => { + if (clean) { + await truncate(); + } + + await client.zadd( + constants.TEST_ZSET_KEY_1, + constants.TEST_ZSET_MEMBER_1_SCORE, + constants.TEST_ZSET_MEMBER_1, + constants.TEST_ZSET_MEMBER_2_SCORE, + constants.TEST_ZSET_MEMBER_2, + ); + + await client.zadd( + constants.TEST_ZSET_KEY_2, + ...(() => { + const toInsert = []; + (new Array(100).fill(0)).map((item, i) => { + toInsert.push(i + 1, `member_${i + 1}`); + }); + return toInsert; + })(), + ); + await client.zadd( + constants.TEST_ZSET_KEY_3, + ...(() => { + const toInsert = []; + (new Array(3000).fill(0)).map((item, i) => { + toInsert.push(i + 1, `member_${i + 1}`); + }); + return toInsert; + })(), + ); + }; + + // Hash + const generateHashes = async (clean: boolean = false) => { + if (clean) { + await truncate(); + } + + await client.hset( + constants.TEST_HASH_KEY_1, + constants.TEST_HASH_FIELD_1_NAME, + constants.TEST_HASH_FIELD_1_VALUE, + constants.TEST_HASH_FIELD_2_NAME, + constants.TEST_HASH_FIELD_2_VALUE, + ); + await client.hset( + constants.TEST_HASH_KEY_2, + ...(() => { + const toInsert = []; + (new Array(3000).fill(0)).map((item, i) => { + toInsert.push(`field_${i + 1}`, `value_${i + 1}`); + }); + return toInsert; + })(), + ); + }; + + // ReJSON-RL + const generateReJSONs = async (clean: boolean = false) => { + if (!get(rte, ['env', 'modules', 'rejson'])) { + return; + } + + if (clean) { + await truncate(); + } + + await executeCommand('json.set', constants.TEST_REJSON_KEY_1, '.', JSON.stringify(constants.TEST_REJSON_VALUE_1)); + await executeCommand('json.set', constants.TEST_REJSON_KEY_2, '.', JSON.stringify(constants.TEST_REJSON_VALUE_2)); + await executeCommand('json.set', constants.TEST_REJSON_KEY_3, '.', JSON.stringify(constants.TEST_REJSON_VALUE_3)); + }; + + const generateHugeNumberOfFieldsForHashKey = async (number: number = 100000, clean: boolean) => { + if (clean) { + await truncate(); + } + + const batchSize = 10000; + let inserted = 0; + do { + const pipeline = []; + const limit = inserted + batchSize; + for (inserted; inserted < limit && inserted < number; inserted++) { + pipeline.push(['hset', constants.TEST_HASH_KEY_1, `f_${inserted}`, 'v']); + } + + await insertKeysBasedOnEnv(pipeline, true); + } while (inserted < number) + }; + + const generateHugeNumberOfTinyStringKeys = async (number: number = 100000, clean: boolean) => { + if (clean) { + await truncate(); + } + + const batchSize = 10000; + let inserted = 0; + do { + const pipeline = []; + const limit = inserted + batchSize; + for (inserted; inserted < limit && inserted < number; inserted++) { + pipeline.push(['set', `k_${inserted}`, 'v']); + } + + await insertKeysBasedOnEnv(pipeline); + } while (inserted < number) + }; + + const generateNKeys = async (number: number = 15000, clean: boolean) => { + await generateAnyKeys([ + { create: n => _.map(new Array(n), (v,i) => ['set', `str_key_${i}`, `str_val_${i}`]) }, // string + { create: n => _.map(new Array(n), (v,i) => ['lpush', `list_key_${i}`, `list_val_${i}`]) }, // list + { create: n => _.map(new Array(n), (v,i) => ['sadd', `set_key_${i}`, `set_val_${i}`]) }, // set + { create: n => _.map(new Array(n), (v,i) => ['zadd', `zset_key_${i}`, 0, `zset_val_${i}`]) }, // zset + { create: n => _.map(new Array(n), (v,i) => ['hset', `hash_key_${i}`, `field`, `hash_val_${i}`]) }, // hash + ], number, clean); + }; + + const generateNReJSONs = async (number: number = 300, clean: boolean) => { + const jsonValue = JSON.stringify(constants.TEST_REJSON_VALUE_1); + await generateAnyKeys([ + { create: n => _.map(new Array(n), (v,i) => ['json.set', `rejson_key_${i}`, '.', jsonValue]) }, + ], number, clean); + }; + + const generateNTimeSeries = async (number: number = 300, clean: boolean) => { + await generateAnyKeys([ + { create: n => _.map(new Array(n), (v,i) => ['ts.create', `ts_key_${i}`, `ts_val_${i}`]) }, + ], number, clean); + }; + + const generateNStreams = async (number: number = 300, clean: boolean) => { + await generateAnyKeys([ + { create: n => _.map(new Array(n), (v,i) => ['xadd', `st_key_${i}`, `*`, `st_field_${i}`, `st_val_${i}`]) }, + ], number, clean); + }; + + const generateNGraphs = async (number: number = 300, clean: boolean) => { + await generateAnyKeys([ + { create: n => _.map(new Array(n), (v,i) => ['graph.query', `graph_key_${i}`, `CREATE (n_${i})`]) }, + ], number, clean); + }; + + return { + executeCommand, + setAclUserRules, + truncate, + generateKeys, + generateHugeNumberOfFieldsForHashKey, + generateHugeNumberOfTinyStringKeys, + generateNKeys, + generateNReJSONs, + generateNTimeSeries, + generateNStreams, + generateNGraphs, + } +} diff --git a/redisinsight/api/test/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts new file mode 100644 index 0000000000..d3b2e2bcf7 --- /dev/null +++ b/redisinsight/api/test/helpers/local-db.ts @@ -0,0 +1,293 @@ +import { Connection, createConnection, getConnectionManager } from 'typeorm'; +import { DatabaseInstanceEntity } from 'src/modules/core/models/database-instance.entity'; +import { SettingsEntity } from 'src/modules/core/models/settings.entity'; +import { AgreementsEntity } from 'src/modules/core/models/agreements.entity'; +import { constants } from './constants'; +import { createCipheriv, createHash } from 'crypto'; + +const repositories = { + INSTANCE: 'DatabaseInstanceEntity', + CA_CERT_REPOSITORY: 'CaCertificateEntity', + CLIENT_CERT_REPOSITORY: 'ClientCertificateEntity', + AGREEMENTS: 'AgreementsEntity', + SETTINGS: 'SettingsEntity' +} + +let localDbConnection; +const getDBConnection = async (): Promise => { + if (!localDbConnection) { + const dbFile = constants.TEST_LOCAL_DB_FILE_PATH; + localDbConnection = await createConnection({ + name: 'integrationtests', + type: "sqlite", + database: dbFile, + entities: [`./../**/*.entity.ts`], + synchronize: false, + migrationsRun: false, + }) + .catch(err => { + if (err.name === "AlreadyHasActiveConnectionError") { + return getConnectionManager().get("default"); + } + throw err; + }); + } + + return localDbConnection; +} + +const getRepository = async (repository: string) => { + return (await getDBConnection()).getRepository(repository); +}; + +const encryptData = (data) => { + if (!data) { + return null; + } + + if (constants.TEST_ENCRYPTION_STRATEGY === 'KEYTAR') { + let cipherKey = createHash('sha256') + .update(constants.TEST_KEYTAR_PASSWORD, 'utf8') + .digest(); + const cipher = createCipheriv('aes-256-cbc', cipherKey, Buffer.alloc(16, 0)); + let encrypted = cipher.update(data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return encrypted; + } + + return data; +} + +const createCACertificate = async (certificate) => { + const rep = await getRepository(repositories.CA_CERT_REPOSITORY); + return rep.save(certificate); +} + +const createClientCertificate = async (certificate) => { + const rep = await getRepository(repositories.CLIENT_CERT_REPOSITORY); + return rep.save(certificate); +} + +const createTesDbInstance = async (rte, server): Promise => { + const rep = await getRepository(repositories.INSTANCE); + + const instance: any = { + id: constants.TEST_INSTANCE_ID, + name: constants.TEST_INSTANCE_NAME, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: constants.TEST_REDIS_USER, + password: encryptData(constants.TEST_REDIS_PASSWORD), + encryption: constants.TEST_ENCRYPTION_STRATEGY, + tls: false, + verifyServerCert: false, + connectionType: rte.env.type, + }; + + if (rte.env.type === constants.CLUSTER) { + instance.nodes = JSON.stringify(rte.env.nodes); + } + + if (rte.env.type === constants.SENTINEL) { + instance.nodes = JSON.stringify([{ + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }]); + instance.sentinelMasterName = constants.TEST_SENTINEL_MASTER_GROUP; + instance.sentinelMasterUsername = constants.TEST_SENTINEL_MASTER_USER; + instance.sentinelMasterPassword = encryptData(constants.TEST_SENTINEL_MASTER_PASS); + } + + if (constants.TEST_REDIS_TLS_CA) { + instance.tls = true; + instance.verifyServerCert = true; + instance.caCert = await createCACertificate({ + id: constants.TEST_CA_ID, + name: constants.TEST_CA_NAME, + encryption: constants.TEST_ENCRYPTION_STRATEGY, + certificate: encryptData(constants.TEST_REDIS_TLS_CA), + }); + + if (constants.TEST_USER_TLS_CERT && constants.TEST_USER_TLS_CERT) { + instance.clientCert = await createClientCertificate({ + id: constants.TEST_USER_CERT_ID, + name: constants.TEST_USER_CERT_NAME, + encryption: constants.TEST_ENCRYPTION_STRATEGY, + certificate: encryptData(constants.TEST_USER_TLS_CERT), + key: encryptData(constants.TEST_USER_TLS_KEY), + }); + } + } + + await rep.save(instance); +} + +export const createDatabaseInstances = async () => { + const rep = await getRepository(repositories.INSTANCE); + const instances = [ + { + id: constants.TEST_INSTANCE_ID_2, + name: constants.TEST_INSTANCE_NAME_2, + host: constants.TEST_INSTANCE_HOST_2, + db: constants.TEST_REDIS_DB_INDEX, + }, + { + id: constants.TEST_INSTANCE_ID_3, + name: constants.TEST_INSTANCE_NAME_3, + host: constants.TEST_INSTANCE_HOST_3, + } + ]; + + for (let instance of instances) { + // await rep.remove(instance); + await rep.save({ + tls: false, + verifyServerCert: false, + host: 'localhost', + port: 3679, + connectionType: 'STANDALONE', + ...instance + }); + } +} + +export const createAclInstance = async (rte, server): Promise => { + const rep = await getRepository(repositories.INSTANCE); + const instance: any = { + id: constants.TEST_INSTANCE_ACL_ID, + name: constants.TEST_INSTANCE_ACL_NAME, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: constants.TEST_INSTANCE_ACL_USER, + password: encryptData(constants.TEST_INSTANCE_ACL_PASS), + encryption: constants.TEST_ENCRYPTION_STRATEGY, + tls: false, + verifyServerCert: false, + connectionType: rte.env.type, + } + + if (rte.env.type === constants.CLUSTER) { + instance.nodes = JSON.stringify(rte.env.nodes); + } + + if (rte.env.type === constants.SENTINEL) { + instance.nodes = JSON.stringify([{ + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }]); + instance.username = constants.TEST_REDIS_USER; + instance.password = constants.TEST_REDIS_PASSWORD; + instance.sentinelMasterName = constants.TEST_SENTINEL_MASTER_GROUP; + instance.sentinelMasterUsername = constants.TEST_INSTANCE_ACL_USER; + instance.sentinelMasterPassword = encryptData(constants.TEST_INSTANCE_ACL_PASS); + } + + if (constants.TEST_REDIS_TLS_CA) { + instance.tls = true; + instance.verifyServerCert = true; + instance.caCert = await createCACertificate({ + id: constants.TEST_CA_ID, + name: constants.TEST_CA_NAME, + encryption: constants.TEST_ENCRYPTION_STRATEGY, + certificate: encryptData(constants.TEST_REDIS_TLS_CA), + }); + + if (constants.TEST_USER_TLS_CERT && constants.TEST_USER_TLS_CERT) { + instance.clientCert = await createClientCertificate({ + id: constants.TEST_USER_CERT_ID, + name: constants.TEST_USER_CERT_NAME, + certFilename: constants.TEST_USER_CERT_FILENAME, + encryption: constants.TEST_ENCRYPTION_STRATEGY, + certificate: encryptData(constants.TEST_USER_TLS_CERT), + key: encryptData(constants.TEST_USER_TLS_KEY), + }); + } + } + + await rep.save(instance); +} + +export const getInstanceByName = async (name: string) => { + const rep = await getRepository(repositories.INSTANCE); + return rep.findOne({ where: { name } }); +} + +export const getInstanceById = async (id: string) => { + const rep = await getRepository(repositories.INSTANCE); + return rep.findOne({ where: { id } }); +} + +export const applyEulaAgreement = async () => { + const rep = await getRepository(repositories.AGREEMENTS); + const agreements: any = await rep.findOne(); + agreements.version = '1.0.0'; + agreements.data = JSON.stringify({eula: true, encryption: true}); + + await rep.save(agreements); +} + +const resetAgreements = async () => { + const rep = await getRepository(repositories.AGREEMENTS); + const agreements: any = await rep.findOne(); + agreements.version = null; + agreements.data = null; + + await rep.save(agreements); +} + +const initAgreements = async () => { + const rep = await getRepository(repositories.AGREEMENTS); + const agreements: any = await rep.findOne(); + agreements.version = constants.TEST_AGREEMENTS_VERSION; + agreements.data = JSON.stringify({ + eula: true, + encryption: constants.TEST_ENCRYPTION_STRATEGY === 'KEYTAR', + }); + + await rep.save(agreements); +} + +export const resetSettings = async () => { + await resetAgreements(); + const rep = await getRepository(repositories.SETTINGS); + const settings: any = await rep.findOne(); + settings.data = null; + + await rep.save(settings); +} + +export const initSettings = async () => { + await initAgreements(); + const rep = await getRepository(repositories.SETTINGS); + const settings: any = await rep.findOne(); + settings.data = null; + + await rep.save(settings); +} + +export const setAppSettings = async (data: object) => { + const rep = await getRepository(repositories.SETTINGS); + const settings: any = await rep.findOne(); + settings.data = JSON.stringify({ + ...JSON.parse(settings.data), + ...data + }); + await rep.save(settings); +} + +const truncateAll = async () => { + await (await getRepository(repositories.INSTANCE)).clear(); + await (await getRepository(repositories.CA_CERT_REPOSITORY)).clear(); + await (await getRepository(repositories.CLIENT_CERT_REPOSITORY)).clear(); + await (await resetSettings()); +} + +export const initLocalDb = async (rte, server) => { + await truncateAll(); + await createTesDbInstance(rte, server); + await initAgreements(); + if (rte.env.acl) { + await createAclInstance(rte, server); + } +} diff --git a/redisinsight/api/test/helpers/redis.ts b/redisinsight/api/test/helpers/redis.ts new file mode 100644 index 0000000000..b37d74b79f --- /dev/null +++ b/redisinsight/api/test/helpers/redis.ts @@ -0,0 +1,212 @@ +import * as Redis from 'ioredis'; +import IORedis from 'ioredis'; +import * as semverCompare from 'node-version-compare'; +import { constants } from './constants'; +import { parseReplToObject, parseClusterNodesResponse } from './utils'; +import { initDataHelper } from './data/redis'; + +/** + * Connect to redis in standalone mode and return client + * @param options + */ +export const connectToStandalone = async ( + options: IORedis.RedisOptions, +): Promise => { + return await new Promise((resolve, reject) => { + const client = new Redis(options); + + client.on('error', (e: Error) => { + console.error('Unable to connect in standalone mode', e); + reject(e); + }); + client.on('ready', () => { + resolve(client); + }); + }); +}; + +/** + * Connect to redis in cluster mode and return client + * @param nodes + * @param redisOptions + */ +export const connectToRedisCluster = async ( + nodes: any[], + redisOptions: IORedis.RedisOptions, +): Promise => { + return await new Promise((resolve, reject) => { + const client = new Redis.Cluster(nodes, { redisOptions }); + + client.on('error', (e: Error): void => { + console.error('Unable to connect in cluster mode', e); + reject(e); + }); + client.on('ready', async () => { + resolve(client); + }); + }); +}; + +/** + * Connect to redis in sentinel mode and return client + * @param redisOptions + */ +export const connectToRedisSentinel = async ( + redisOptions: IORedis.RedisOptions, +): Promise => { + return await new Promise((resolve, reject) => { + const client = new Redis(redisOptions); + + client.on('error', (e: Error): void => { + console.error('Unable to connect in sentinel mode', e); + reject(e); + }); + client.on('ready', async () => { + resolve(client); + }); + }); +}; + +/** + * Automatically determines connection mode and returns client + * @param connectionOptions + */ +const getClient = async ( + connectionOptions: IORedis.RedisOptions, +): Promise> => { + let standaloneClient = await connectToStandalone(connectionOptions); + const info: any = { + type: constants.STANDALONE, + }; + + // check for cluster + try { + const clusterInfo = parseReplToObject( + await standaloneClient.send_command('cluster', ['info']), + ); + if (clusterInfo.cluster_state === 'ok') { + const nodes = parseClusterNodesResponse( + await standaloneClient.send_command('cluster', ['nodes']), + ) + .filter((node) => node.linkState === 'connected') + .map(({ host, port }) => { + return { host, port }; + }); + if (nodes.length > 0) { + info.type = constants.CLUSTER; + return { + client: await connectToRedisCluster(nodes, connectionOptions), + info, + }; + } + } + } catch (e) {} + + // check for sentinel + try { + const masterGroups = await standaloneClient.send_command('sentinel', ['masters']); + if (!masterGroups?.length) { + throw new Error('Invalid sentinel configuration') + } + info.type = constants.SENTINEL; + const sentinelOptions = { + sentinels: [{ + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }], + name: constants.TEST_SENTINEL_MASTER_GROUP, + sentinelUsername: constants.TEST_REDIS_USER, + sentinelPassword: constants.TEST_REDIS_PASSWORD, + username: constants.TEST_SENTINEL_MASTER_USER, + password: constants.TEST_SENTINEL_MASTER_PASS, + connectionName: connectionOptions.connectionName, + }; + return { + client: await connectToRedisSentinel(sentinelOptions), + info, + }; + } catch (e) {} + + return { client: standaloneClient, info }; +}; + + +let rte; +/** + * Create test Redis client and determine environment settings + */ +export const initRTE = async () => { + if (!rte) { + const options: IORedis.RedisOptions = { + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: constants.TEST_REDIS_USER, + password: constants.TEST_REDIS_PASSWORD, + showFriendlyErrorStack: true, + connectionName: constants.TEST_RUN_ID, + }; + + if (constants.TEST_REDIS_TLS_CA) { + if (!constants.TEST_USER_TLS_CERT || !constants.TEST_USER_TLS_CERT) { + options.tls = { + rejectUnauthorized: true, + checkServerIdentity: () => undefined, + ca: [constants.TEST_REDIS_TLS_CA], + }; + } else { + options.tls = { + rejectUnauthorized: true, + checkServerIdentity: () => undefined, + ca: [constants.TEST_REDIS_TLS_CA], + cert: constants.TEST_USER_TLS_CERT, + key: constants.TEST_USER_TLS_KEY, + }; + } + } + + rte = await getClient(options); + } + + const info = parseReplToObject(await rte.client.send_command('info')); + + rte.env = { + name: constants.TEST_RUN_NAME, + version: info['redis_version'], + mode: info['redis_mode'], + type: rte.info.type, + onPremise: constants.TEST_RTE_ON_PREMISE, + // ACL commands are blocked in the Redis Enterprise and Cloud + acl: !constants.TEST_CLOUD_RTE && !constants.TEST_RE_USER && semverCompare(info['redis_version'], '6') >= 0, + pass: !!constants.TEST_REDIS_PASSWORD, + tls: !!constants.TEST_REDIS_TLS_CA, + tlsAuth: !!constants.TEST_USER_TLS_KEY && !!constants.TEST_USER_TLS_CERT, + modules: await determineModulesInstalled(rte.client), + re: !!constants.TEST_RE_USER, + cloud: !!constants.TEST_CLOUD_RTE, + nodes: [], + }; + + if (rte.env.type === constants.CLUSTER) { + rte.env.nodes = rte.client.nodes('all').map(({ options }) => { + return { host: options.host, port: options.port }; + }); + } + + rte.data = await initDataHelper(rte); + + return rte; +}; + +const determineModulesInstalled = async (client) => { + const modules = {}; + try { + (await client.send_command('module', 'list')) + .map(module => { + modules[module[1].toLowerCase()] = { version: module[3] || -1 }; + }); + } catch (e) { + console.error('Error when try to indicate modules installed: ', e); + } + + return modules; +}; diff --git a/redisinsight/api/test/helpers/server.ts b/redisinsight/api/test/helpers/server.ts new file mode 100644 index 0000000000..a7379e61c8 --- /dev/null +++ b/redisinsight/api/test/helpers/server.ts @@ -0,0 +1,44 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from 'src/app.module'; +import * as bodyParser from 'body-parser'; +import { constants } from './constants'; + +/** + * TEST_BE_SERVER - url to already running API that we want to test + * When not defined We will up and run local server + */ +export let server = process.env.TEST_BE_SERVER; +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +/** + * Initiate server if needed (only once) + */ +export const getServer = async () => { + try { + const keytar = require('keytar'); + let keytarPassword = await keytar.getPassword('redisinsight', 'app'); + if (!keytarPassword) { + await keytar.setPassword('redisinsight', 'app', constants.TEST_KEYTAR_PASSWORD); + } + else { + constants.TEST_KEYTAR_PASSWORD = keytarPassword; + } + } catch (e) { + constants.TEST_ENCRYPTION_STRATEGY = 'PLAIN'; + } + + if (!server) { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const app = moduleFixture.createNestApplication(); + app.use(bodyParser.json({ limit: '512mb' })); + app.use(bodyParser.urlencoded({ limit: '512mb', extended: true })); + + await app.init(); + server = await app.getHttpServer(); + } + + return server; +} diff --git a/redisinsight/api/test/helpers/test.ts b/redisinsight/api/test/helpers/test.ts new file mode 100644 index 0000000000..aac6ebd1f7 --- /dev/null +++ b/redisinsight/api/test/helpers/test.ts @@ -0,0 +1,178 @@ +import { describe, it, before, after, beforeEach } from 'mocha'; +import * as util from 'util'; +import * as _ from 'lodash'; +import * as fs from 'fs'; +import * as chai from 'chai'; +import * as Joi from 'joi'; +import * as diff from 'object-diff'; +import { cloneDeep, isMatch, isObject, set } from 'lodash'; +import { generateInvalidDataArray } from './test/dataGenerator'; + +export { _, fs } +export const expect = chai.expect; +export const testEnv: Record = {}; +export { Joi, describe, it, before, after, beforeEach }; + +export * from './test/conditionalIgnore'; +export * from './test/dataGenerator'; + +interface ITestCaseInput { + endpoint: Function; // function that returns prepared supertest with url + data?: any; + query?: any; + statusCode?: number; + responseSchema?: Joi.AnySchema; + responseBody?: any; + checkFn?: Function; + preconditionFn?: Function; + postCheckFn?: Function; +} + +/** + * Common validation function + * @param ITestCaseInput + */ +export const validateApiCall = async function ({ + endpoint, + data, + query, + statusCode = 200, + responseSchema, + responseBody, + checkFn, +}: ITestCaseInput): Promise { + const request = endpoint(); + + // data to send with POST, PUT etc + if (data) { + request.send(data); + } + + // data to send with url query string + if (query) { + request.query(query); + } + + request.expect(statusCode); + + const response = await request; + + // custom function to check conditions + if (checkFn) { + await checkFn(response); + } + + // check response body (not deep strict) + if (responseBody) { + checkResponseBody(response.body, responseBody); + } + + // validate response schema if passed + if (responseSchema) { + Joi.assert(response.body, responseSchema); + } + + return response; +}; + +/** + * Checks if values from "expected" persist in body + * Can receive more fields from API ("body") but will check values from "expected" only + * + * @param body + * @param expected + */ +export const checkResponseBody = (body, expected) => { + try { + if (isObject(expected)) { + return expect(isMatch(body, expected)).to.eql(true); + } + // todo: improve to support array, arrays of objects etc. + expect(expected).to.eql(body); + } catch (e) { + const errorMessage = 'Response does not includes expected value(s)' + + '\nExpect:\n' + util.inspect(body, { depth: null }) + + '\nTo include:\n' + util.inspect(expected, { depth: null }) + + '\nDiff:\n' + util.inspect(diff(body, expected), { depth: null }); + + throw new Error(errorMessage); + } +}; + +const defaultValidationErrorMessages = { + 'any.required': '{#label} should not be null or undefined', + 'any.only': '{#label} must be a valid enum value', + 'array.base': '{#label} must be an array', + 'string.base': `{#label} must be a string`, + 'string.empty': `{#label} should not be null or undefined`, + 'number.base': `{#label} must be an integer number`, + 'number.integer': `{#label} must be an integer number`, + 'number.min': `{#label} must not be less than {#min}`, + 'number.max': `{#label} must not be greater than {#max}`, + 'string.min': `{#label} must be longer than or equal to {#limit} characters`, + 'string.max': `{#label} must be shorter than or equal to {#limit} characters`, + 'object.base': `must be either object or array`, +}; + +/** + * Common test case for input data validation + * + * @param endpoint + * @param schema + * @param target + */ +export const validateInvalidDataTestCase = (endpoint, schema, target = 'data') => { + return (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + statusCode: 400, + checkFn: badRequestCheckFn(schema, testCase[target]), + ...testCase, + }); + }); + }; +}; + +/** + * Custom check for API response for validation error + * @param schema + * @param data + */ +const badRequestCheckFn = (schema, data) => { + return ({ body }) => { + expect(body.statusCode).to.eql(400); + expect(body.error).to.eql('Bad Request'); + + // check expected error messages using validation schema + const { error } = schema.validate(data, { + abortEarly: true, + errors: { wrap: { label: false } }, + messages: defaultValidationErrorMessages, + }); + error.details.map(({ message }) => { + expect(body.message.join()).to.have.string(message); + }); + }; +}; + +/** + * Generates input data for validation test case based on Joi schema + * + * @param schema + * @param validData + * @param target + */ +export const generateInvalidDataTestCases = ( + schema, + validData, + target = 'data', +) => { + return generateInvalidDataArray(schema).map(({ path, value }) => { + return { + name: `Validation error when ${target}: ${path.join('.')} = "${value}"`, + [target]: path?.length ? set(cloneDeep(validData), path, value) : value, + }; + }); +}; + diff --git a/redisinsight/api/test/helpers/test/conditionalIgnore.ts b/redisinsight/api/test/helpers/test/conditionalIgnore.ts new file mode 100644 index 0000000000..6635ca1f86 --- /dev/null +++ b/redisinsight/api/test/helpers/test/conditionalIgnore.ts @@ -0,0 +1,100 @@ +import { before } from 'mocha'; +import { get, has } from 'lodash'; +import * as semverCompare from 'node-version-compare'; +import { testEnv } from '../test'; + +/** + * Function to run tests by condition + * Used inside "describe" function only + * note: add support "it" if needed + * @param conditions + */ +export const requirements = function (...conditions) { + before(function () { + for (let cond of conditions) { + switch (typeof cond) { + case 'function': + if (!cond()) { + this.skip(); + } + break; + case 'string': + if(!processConditionString(cond)) { + this.skip(); + } + break; + default: + throw new Error(`Unsupported condition type ${cond}`); + } + } + }); +} + + +const cmdReg = /^([?!\w\.]+)(\s?[=<>]+)?(\s?[\w\.]+)?$/; +const processConditionString = (condition: string): boolean => { + if (!cmdReg.test(condition)) { + throw new Error('Unsupported condition structure'); + } + + const args = (condition.match(cmdReg)).filter(val => val !== undefined); + + switch (args.length) { + case 2: + return checkBooleanCondition( + args[1].replace(/^!+|!+$/, ''), + args[1][0] === '!' + ); + case 4: + return checkStringCondition( + args[1].replace(/^!+|!+$/, ''), + args[2].trim(), + args[3].trim(), + args[1][0] === '!' + ); + default: + throw new Error('Unsupported condition structure'); + } +} + +const checkBooleanCondition = (path: string, inverse = false): boolean => { + const check = !!get(testEnv, path); + return inverse ? !check : check; +} + +const checkStringCondition = (path: string, expression: string, targetValue: string, inverse = false): boolean => { + if (!has(testEnv, path)) { + throw new Error(`Test environment does not has such path: ${path}`); + } + + const inputValue = get(testEnv, path); + const isSemver = path.indexOf('version') > -1; + let check: boolean + switch (expression) { + case '=': + case '==': + case '===': + check = compareValues(inputValue, targetValue, isSemver) === 0 + break; + case '>': + check = compareValues(inputValue, targetValue, isSemver) === 1; + break; + case '>=': + check = compareValues(inputValue, targetValue, isSemver) >= 0; + break; + case '<': + check = compareValues(inputValue, targetValue, isSemver) === -1; + break; + case '<=': + check = compareValues(inputValue, targetValue, isSemver) <= 0; + break; + } + return inverse ? !check : check; +} + +const compareValues = (inputValue: string, targetValue: string, semver: boolean = false): number => { + if (semver) return semverCompare(inputValue, targetValue); + if (inputValue == targetValue) return 0; + if (inputValue > targetValue) return 1; + if (inputValue < targetValue) return -1; +} diff --git a/redisinsight/api/test/helpers/test/dataGenerator.ts b/redisinsight/api/test/helpers/test/dataGenerator.ts new file mode 100644 index 0000000000..5f41fb7678 --- /dev/null +++ b/redisinsight/api/test/helpers/test/dataGenerator.ts @@ -0,0 +1,131 @@ +/** + * Generates invalid data based on Joi schema + * + * @param schema + * @param path + * @param cases + */ +export const generateInvalidDataArray = (schema, path = [], cases = []) => { + if (schema._flags?.presence === 'required') { + cases.push({ path, value: undefined }); + } + + const allowedValues = []; + if (schema._valids?._values?.size) { + schema._valids._values.forEach(value => allowedValues.push(value)); + } + + switch (schema.type) { + case 'object': + // if nested object + if (path?.length) { + if (!allowedValues.some(allowed => allowed === null)) { + cases.push({ path, value: null }); + } + cases.push({ path, value: 'somestring' }); + cases.push({ path, value: 100 }); + cases.push({ path, value: 100.12 }); + cases.push({ path, value: true }); + } + + const keys = schema._ids._byKey; + if (keys.size) { + keys.forEach((key) => { + generateInvalidDataArray(key.schema, [...path, key.id], cases); + }); + } + break; + case 'array': + // if nested array + if (path?.length) { + if (!allowedValues.some(allowed => allowed === null)) { + cases.push({ path, value: null }); + } + cases.push({ path, value: 'somestring' }); + cases.push({ path, value: 100 }); + cases.push({ path, value: 100.12 }); + cases.push({ path, value: true }); + // cases.push({ path, value: { some: 'object' } }); + } + + const items = schema.$_terms.items; + if (items.length) { + items.forEach((item) => { + generateInvalidDataArray(item, [...path, 0], cases); + }); + } + break; + case 'string': + [null, 100, 100.12, true, { some: 'object' }, ['some', 'array']] + .map(value => { + if (!allowedValues.some(allowed => allowed === value)) { + cases.push({ path, value }); + } + }); + + // check for additional rules + if (schema._singleRules?.size) { + schema._singleRules.forEach((rule) => { + switch (rule.name) { + case 'min': + cases.push({ path, value: 'a'.repeat(rule.args.limit - 1) }); + break; + case 'max': + cases.push({ path, value: 'a'.repeat(rule.args.limit + 1) }); + break; + default: + throw new Error( + `Unsupported rule ${rule.name}. Need to implement...`, + ); + } + }); + } + break; + case 'number': + [null, 'stringvalue', true, { some: 'object' }, ['some', 'array']] + .map(value => { + if (!allowedValues.some(allowed => allowed === value)) { + cases.push({ path, value }); + } + }); + + // check for additional rules + if (schema._singleRules?.size) { + schema._singleRules.forEach((rule) => { + switch (rule.name) { + case 'integer': + cases.push({ path, value: 11.11 }); + break; + case 'min': + cases.push({ path, value: rule.args.limit - 1 }); + break; + case 'max': + cases.push({ path, value: rule.args.limit + 1 }); + break; + default: + throw new Error( + `Unsupported rule ${rule.name}. Need to implement...`, + ); + } + }); + } + break; + case 'boolean': + [null, 'stringvalue', 100, 100.12, { some: 'object' }, ['some', 'array']] + .map(value => { + if (!allowedValues.some(allowed => allowed === value)) { + cases.push({ path, value }); + } + }); + break; + case 'any': + // ignore "any" type + break; + default: + throw new Error( + `Data generation doesn't support ${schema.type}. Need to implement...`, + ); + } + + return cases; +}; diff --git a/redisinsight/api/test/helpers/utils.ts b/redisinsight/api/test/helpers/utils.ts new file mode 100644 index 0000000000..51f8f49f6c --- /dev/null +++ b/redisinsight/api/test/helpers/utils.ts @@ -0,0 +1,51 @@ +/** + * Parses Redis REPL info responses to object + * @param data + */ +export const parseReplToObject = (data: string): Record => { + try { + const obj = {}; + + data.split('\r\n').map((line) => { + if (!line) return; + + const fields = line.match(/^(.+):(.+)$/); + fields ? (obj[fields[1]] = fields[2]) : null; + }); + + return obj; + } catch (e) { + console.error('Error when trying to parse REPL object response', e); + return {}; + } +}; + +/** + * Parses Redis REPL cluster nodes command response + * @param data + */ +export const parseClusterNodesResponse = (data: string): Record[] => { + try { + const nodes = []; + + data.split('\n').map((line) => { + if (!line) return; + + const fields = line.split(' '); + const [id, endpoint, , master, , , , linkState, slot] = fields; + nodes.push({ + id, + host: endpoint.split(':')[0], + port: parseInt(endpoint.split(':')[1].split('@')[0], 10), + replicaOf: master !== '-' ? master : undefined, + linkState, + slot, + }); + }); + + return nodes; + } catch (e) { + console.error('Error when trying to parse REPL array response', e); + return []; + } +}; diff --git a/redisinsight/api/test/test-runs/cloud-st/.env b/redisinsight/api/test/test-runs/cloud-st/.env new file mode 100644 index 0000000000..f8d9a80c7e --- /dev/null +++ b/redisinsight/api/test/test-runs/cloud-st/.env @@ -0,0 +1 @@ +TEST_CLOUD_RTE=true diff --git a/redisinsight/api/test/test-runs/cloud-st/docker-compose.yml b/redisinsight/api/test/test-runs/cloud-st/docker-compose.yml new file mode 100644 index 0000000000..89e62433e9 --- /dev/null +++ b/redisinsight/api/test/test-runs/cloud-st/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.4" + +services: + test: + env_file: + - ./cloud-st/.env + environment: + TEST_CLOUD_API_KEY: ${TEST_CLOUD_API_KEY} + TEST_CLOUD_API_SECRET_KEY: ${TEST_CLOUD_API_SECRET_KEY} + redis: + image: node:14.17-alpine + entrypoint: [ "echo", "Dummy Service" ] diff --git a/redisinsight/api/test/test-runs/docker.build.env b/redisinsight/api/test/test-runs/docker.build.env new file mode 100644 index 0000000000..5447d87e74 --- /dev/null +++ b/redisinsight/api/test/test-runs/docker.build.env @@ -0,0 +1,6 @@ +APP_DATA_HOMEDIR=/root/.redisinsight-v2.0 +COV_FOLDER=./coverage +ID=defaultid +RTE=defaultrte +APP_IMAGE=riv2:latest +TEST_BE_SERVER=https://app:5000/api diff --git a/redisinsight/api/test/test-runs/docker.build.yml b/redisinsight/api/test/test-runs/docker.build.yml new file mode 100644 index 0000000000..0938a58833 --- /dev/null +++ b/redisinsight/api/test/test-runs/docker.build.yml @@ -0,0 +1,45 @@ + +# Base compose file that includes all BE, RTE builds +version: "3.4" + +x-constants: + - &apiRoot ./../../ + +services: + test: + cap_add: + - ipc_lock + build: + context: *apiRoot + dockerfile: ./test/test-runs/test.Dockerfile + tty: true + volumes: + - ${COV_FOLDER}:/usr/src/app/coverage + - ${COV_FOLDER}:/root/.redisinsight-v2.0 + depends_on: + - redis + - app + environment: + TEST_REDIS_HOST: "redis" + DB_SYNC: "true" + TEST_BE_SERVER: ${TEST_BE_SERVER} + TEST_LOCAL_DB_FILE_PATH: "/root/.redisinsight-v2.0/redisinsight.db" + SECRET_STORAGE_PASSWORD: "somepassword" + app: + cap_add: + - ipc_lock + image: ${APP_IMAGE} + depends_on: + - redis + volumes: + - ${COV_FOLDER}:/root/.redisinsight-v2.0 + environment: + DB_SYNC: "true" + DB_MIGRATIONS: "false" + APP_FOLDER_NAME: ".redisinsight-v2.0" + SECRET_STORAGE_PASSWORD: "somepassword" + +networks: + default: + name: ${ID} + diff --git a/redisinsight/api/test/test-runs/local.build.env b/redisinsight/api/test/test-runs/local.build.env new file mode 100644 index 0000000000..cb8fc05435 --- /dev/null +++ b/redisinsight/api/test/test-runs/local.build.env @@ -0,0 +1,4 @@ +APP_DATA_HOMEDIR=/root/.redisinsight-2.0 +COV_FOLDER=./coverage +ID=defaultid +RTE=defaultrte diff --git a/redisinsight/api/test/test-runs/local.build.yml b/redisinsight/api/test/test-runs/local.build.yml new file mode 100644 index 0000000000..d5c11a8c20 --- /dev/null +++ b/redisinsight/api/test/test-runs/local.build.yml @@ -0,0 +1,27 @@ +# Base compose file that includes all BE, RTE builds +version: "3.4" + +x-constants: + - &apiRoot ./../../ + +services: + test: + cap_add: + - ipc_lock + build: + context: *apiRoot + dockerfile: ./test/test-runs/test.Dockerfile + tty: true + volumes: + - ${COV_FOLDER}:/usr/src/app/coverage + depends_on: + - redis + environment: + TEST_REDIS_HOST: "redis" + # dummy service to prevent docker validation errors + app: + image: node:14.17-alpine + +networks: + default: + name: ${ID} diff --git a/redisinsight/api/test/test-runs/mods-preview/docker-compose.yml b/redisinsight/api/test/test-runs/mods-preview/docker-compose.yml new file mode 100644 index 0000000000..c5734f281a --- /dev/null +++ b/redisinsight/api/test/test-runs/mods-preview/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3.4" + +services: + test: + environment: + - 'TEST_RUN_NAME=MODS_PREVIEW' + redis: + image: redislabs/redismod:preview diff --git a/redisinsight/api/test/test-runs/oss-clu-tls/.env b/redisinsight/api/test/test-runs/oss-clu-tls/.env new file mode 100644 index 0000000000..b10bb5b1ff --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-clu-tls/.env @@ -0,0 +1 @@ +TEST_REDIS_TLS_CA="-----BEGIN CERTIFICATE-----\nMIIFHzCCAwegAwIBAgIUKeAfHPO6uJBW+s8fY2cWKOc+DfgwDQYJKoZIhvcNAQEL\nBQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTMy\nNzI2WhcNMzExMDI2MTMyNzI2WjAfMQswCQYDVQQGEwJBVTEQMA4GA1UEAwwHZXhh\nbXBsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANF9A4aeHru2fX1j\nU+Bz9D2supYsMG64f+wXNrFPTMxPS/rdjNcqAWeVCknY7d8EO0uBf64Gm4ufQAPV\nboINIdgoso9tGfl5LMaaiYq0aD5CK0wmU38pPbKA2Vr9bkrNIYLUFU6oPI7RJ5fL\nPl/vbvHyaXQKcDd5xxusAu3Ytrylq3WaLNWwhT//WRor4SU2qt9s06PiOgCABY+D\nolMXI72gDaehRhnbOVXc6GadlHCsE5GHYJ3WcLLY0rGEdlwphcEG5TRVHGBiHOg/\nJ0vsiuhwTLyRqQq5L6eFm33d4aRI9JLY8LlU5ywGiVoNl+fFdQr3ovWw7eObQSbg\nBuOJhQBTpEmiPgiOC3kAUUrgT/uGS1x9RX+Wj0sY6zs+qOkfhFAcScXQBeZSLNT9\nRYAjZQOTtTQYVwH8NcF2MlwI3tb3qk2+2Xby4YfTHxp42B8IHkedwfFzrwfUDnNM\nCm3GSVtDGv/j4/7fp0oZZROpd5+h1wRhR/HO08rkpwuobo6xGRrrxqbdlsid3OB4\nKk92Wl8reccxyr2a/7OlrWk284xpQI/nlU6a8bByJp2eTPYuNJFfJkrqua94YOJy\nK4d4tLNbQ4X/5g12unGEHg8/HVNHJjCKiU2Gwxhxm50EqmgdgaboDmf+GuVF0tL1\nkGPbbjSrlt2pS+Tdza9EJfmAzVppAgMBAAGjUzBRMB0GA1UdDgQWBBQWqqhnKa+s\n5ToC0TYKlIdfytda/jAfBgNVHSMEGDAWgBQWqqhnKa+s5ToC0TYKlIdfytda/jAP\nBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQATnfNftaIalbJmNHrq\n7KIxyAAEimiIvci5i1f2X8ZB4XY1TWcf0eQ/+oJW9E2Kj83yGaAqKg1FFP8ugquG\npxzMyljTlQi3cWp58ByaYg2XMEyRY5FtbcdbHHJgdM2PSHsegWN/vfJw3H3J2673\nJ6Kc/69IwJcE+aYDbLa/cnnRBn9AKHdtNoRmPH+s162oo8I86LQuG942Hx2CHCjt\nttkLwVBtsBKc5hRaPR+Psx68jS41iHJGHUwHNcPl7llBAQe8kKNg4GHJWT5vh7rd\nrw4jAGCsoaE5Ol1HyRDprpdcC4o+eQbhMrjMcFzYduuCx1F96BlSXA2mQe+9lD08\nLzdS35ILmSqCTbtOcdHCmnjWp9fhl5mIrJ+I3G33QPHaHJXBfWGNidxjkibwdVxK\neNAOv4lEMCoVQ0occzNyUluJQUFJyvXtXWFSErRH6b78Gsc/AvPijPbSNuT8hRK9\nGC3yRYltDFXcr4+2lxJyoQoR6Y8399oaJm4U17fOIwlM+iI8dT8x+qsT8brw+5kk\noKu4jz8jfkZrUF8V8hIfAUc08IvMAmDwvIMeAjXFmDBEECxXBGRw2hTcY/53Nfdt\nPRWzaL/YtKOy/9UfJQKs3Ihqte59/v3EJO9x9vTLmcpoCgh9piNVgD6OS38cOiEa\nsnS90+qMig9Gx3aJ+UvktWcp3Q==\n-----END CERTIFICATE-----\n" diff --git a/redisinsight/api/test/test-runs/oss-clu-tls/Dockerfile b/redisinsight/api/test/test-runs/oss-clu-tls/Dockerfile new file mode 100644 index 0000000000..d70752c4cd --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-clu-tls/Dockerfile @@ -0,0 +1,13 @@ +FROM bitnami/redis-cluster:6.0.8 + +ENV ALLOW_EMPTY_PASSWORD yes + +# TLS options +ENV REDIS_TLS_ENABLED yes +ENV REDIS_TLS_PORT 6379 +ENV REDIS_TLS_CERT_FILE /opt/bitnami/redis/certs/redis.crt +ENV REDIS_TLS_KEY_FILE /opt/bitnami/redis/certs/redis.key +ENV REDIS_TLS_CA_FILE /opt/bitnami/redis/certs/redisCA.crt +ENV REDIS_TLS_AUTH_CLIENTS no + +COPY --chown=1001 ./certs /opt/bitnami/redis/certs/ diff --git a/redisinsight/api/test/test-runs/oss-clu-tls/certs/redis.crt b/redisinsight/api/test/test-runs/oss-clu-tls/certs/redis.crt new file mode 100644 index 0000000000..2761116425 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-clu-tls/certs/redis.crt @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEzTCCArWgAwIBAgIUALiX/81ndGTG8UCPzu8r4Ev2IhEwDQYJKoZIhvcNAQEL +BQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTMy +NzI3WhcNMzExMDI2MTMyNzI3WjANMQswCQYDVQQGEwJBVTCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAM4osMW/hBlde+/E20wP9X+zJ0AMD6OtfhqQ5brC +gbVs9mPccZ/8R0fj83YtZfnoEodMZ/7yUTeCCPMdprAezMU1KBf9EpZmTdWhpO3e +kESHcQdsKkqGtyjYF7dDahTmKt4a4aHlPH0DJLltB5HlbVabkzlo+3S8QaNwH5lY +yJTQIqiqVzs9oRLT76nZuJjsym0dNXE42rO3KCniI6kvJDmUzBD8Wc94iDExfy7q +qHyV7b2DCp1w7XP4yrQAFQ6kiVqNcfTTAO4MHNP54V2nZLPdOsUD5BsYY8hu0HDc +/PisZ9ZMcw7LMfpUd3dfA9zefQXPQsWJK20ZCNmdIFtwvIFUpYu/FEF3FWO83zeI +XkVZiuCOnoFvp8JIDvXgzXUBWzvYmxLqVSZAuabqU5pKRswDPLGlZTkHbuP2DiXD +LD5AsTnICpzYkUeERSZKf2qc/nTUk04W/7FUT/75ItVzZvu90mPJlmArB0j4zdAG +KwKo8v/cF1hA1YznhibxcUAA/Q/O3Y6CPQ7C3NeaGKcycgUxWoEY3Leno40ukijd +R0MvsaY7V0/up37fkPtH9rcCkZOGVT5Q4Ww9gVO3yseXVkxbJyzHV1tuwg6yY9wO +LOU2Bbmazkjlkb8a5OyQol2zZNJ0L3lvRWTildtGUTsBkeqI6HAedTTHJkhjHh/P +P0+TAgMBAAGjEzARMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIB +AKn+aH60zdd4ItMOhgd4BIok/u6S4NF0oe4kDwC/EU5C0hbuT6gLAn7ArHMeadue +OuSnhqIPfxNR9WCes9OU1O00WeCuWCE4XfNMMFN5iDFfLxO4Oy5Adt0T74hWbcy6 +h28TdcjrkJEr7HR59G5tQ8TW5gVB4a0WXDw0ob9DSxbFKZU1uZm9L/+MgB/SNCHL +GZSKt75Z/M10b9BTC3OG9swsoWvXEjR2ICiwzk+LxVf5K38faDyBrNJVglrpEUZz +gP60kL73qK0y1/i35UuP0yIJIy48XnDsSByN7eBVsNTGMW3CFLKWA4RVfnEHNUff +vsLHXZFYsUIPnPc5jksFwb/wKAe9JbCrgQPhBYaIYkRGiYt64C48r3boIIVoz9+1 +9Nq0Ik06fCzlI9APq2nzEiVeB7mDyZ692neu32QM6zRkYor+W8uI21YnRJWlOx7+ +x2GIh2EZnEYNvbpbvk/fV5AqkYOu9auRAkcKfME7dJ3Gwndl0YBOjE2DMTv6vIjS +dVuGXQCvlzkRAnPMh5MR5/bSUKVvBryXs9ecAMgoVXBVB+4tGWct5ziL+8qyNtgA +WJ2EWj3xtLlMwwQmLjRsCrZjL4liLJG8Yn8Ehfq1rRJREH2O8uYKCO1fdhuI0Y5S +iBPfqJi6QBHj7i01K9OpNUB7l+xAFLA3cBsegcm2GPoL +-----END CERTIFICATE----- diff --git a/redisinsight/api/test/test-runs/oss-clu-tls/certs/redis.key b/redisinsight/api/test/test-runs/oss-clu-tls/certs/redis.key new file mode 100644 index 0000000000..fb0777e3ea --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-clu-tls/certs/redis.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDOKLDFv4QZXXvv +xNtMD/V/sydADA+jrX4akOW6woG1bPZj3HGf/EdH4/N2LWX56BKHTGf+8lE3ggjz +HaawHszFNSgX/RKWZk3VoaTt3pBEh3EHbCpKhrco2Be3Q2oU5ireGuGh5Tx9AyS5 +bQeR5W1Wm5M5aPt0vEGjcB+ZWMiU0CKoqlc7PaES0++p2biY7MptHTVxONqztygp +4iOpLyQ5lMwQ/FnPeIgxMX8u6qh8le29gwqdcO1z+Mq0ABUOpIlajXH00wDuDBzT ++eFdp2Sz3TrFA+QbGGPIbtBw3Pz4rGfWTHMOyzH6VHd3XwPc3n0Fz0LFiSttGQjZ +nSBbcLyBVKWLvxRBdxVjvN83iF5FWYrgjp6Bb6fCSA714M11AVs72JsS6lUmQLmm +6lOaSkbMAzyxpWU5B27j9g4lwyw+QLE5yAqc2JFHhEUmSn9qnP501JNOFv+xVE/+ ++SLVc2b7vdJjyZZgKwdI+M3QBisCqPL/3BdYQNWM54Ym8XFAAP0Pzt2Ogj0OwtzX +mhinMnIFMVqBGNy3p6ONLpIo3UdDL7GmO1dP7qd+35D7R/a3ApGThlU+UOFsPYFT +t8rHl1ZMWycsx1dbbsIOsmPcDizlNgW5ms5I5ZG/GuTskKJds2TSdC95b0Vk4pXb +RlE7AZHqiOhwHnU0xyZIYx4fzz9PkwIDAQABAoICAHyc/+0oDHNAnK+bsGrTorNj +2S/Pmox3TChGuXYgKEM/79cA4vWvim6cDQe7/U4Hx1tdBeeHFSyWP06k96Kxm1kA +/pExedDLWfTt1kGqLE4gCGRSL2YI9CGOLRerei3TysmiOgygAeYWxlYG33KC2Ypm +U6F6IbS4LnzaQ19v2R6KiMim3j+CyyAUV2O1pO1bBCjcZPdhRGEpLu/SL3gOdLkR +hiAmSSstUjVaE+SKFvnnrmLFGN996SoWkoAnJJNLRXMk2GMCQCejzrEa8+ymSCqo +aOO5rGHsZjQ7N2dhTNALdmCEqW+hxz3nXKcdGbqiCbQ/Sb8ZYNR7M2xGm85p4Ka9 +0UK4cOM1VJPwz8hotSKAUmXnpbu73CsZi5HyzwZkk4FpcaYCYrCbGVmm1cIKEKI7 +8SN/oqgFdj4Ha9cemnu+RecQZouK+wPWtcILd2kstJl52TV952fVOrnXQDo6XCXB +fbs9IYN1hB6N79xv4L7Jj53hSRMeSNf70Ejkh1FXPOvmvFT12wy5JQdBBR5nnb4a +GEsMpGVe1k3bxjK7K263tLSH0UZ8dMgdSx1E4D1hT1K/gEwTSMOJ0E1R0M6SJmF2 +6TnZ0MbJWx6PbICmyZrd2agfTQrq6CgY1fWLGbQrtnwXtsUR7PiHarydXfs3V8g1 +xHnK1bItOBBMOMcWV93hAoIBAQD2xMceUfBUN0TeWrJ4izD3lUYxmWqjTg7kPcjJ +0VN8v3txGAcyPmbuz5AEzvdFjTispoZNB9QOrmsUVWTQDE5otnei9kHWzqJWYHg4 +USuUuAh8OJGCiepo8zHT3qHDNhKGtOAp5LC8TaOznUFr35zCBCOsvQfRUKrv5IOc +vCFjO07Xly8+M3qK7/UswRQ6480VlE2t1p+VNaORHdTDg2tes3/9owuiNmR/sPT8 +nIoe01LS7qmZoiB1vracaLcBf1Iwd7RvKg7mgFJzmowZUYxyX2YGK5qZ1h74In2X +55+qQnNW0RwPijopTv711pMhMKWl8i3ilcCfoeBXz8zCwFfbAoIBAQDV3wHAO7ic +MYP/Bm5jgIwlix1eOWY/yB+VqdYn2GmC49vTEIlIVlFRq0vZE06iUxs87BIV08zO +4w/iKXd7ktkuhphiEuU2yXA3LQPHpbSOW43RONbf4glFU/DlP/P6fiybbWj6+f7L +7Zbvtz5AW03Y4ZpagJTqOgVdJ0MdLnh9vZj6okGGV1fidKtG6hr7Z/oLhnl9aAZK +4vrvBZ//qz99vEVByiV5kRaJDulu+diBy4n6iBjzjHA5a9e7lY3sUBw3DMgb7kYs +JJPkCPdSxCYq4Ef3z/Eao0tyUuCzyznfCMGJA1gBdTpwDNDCTaXqkjR5nvsdE5k0 +IVQgFPtcOPCpAoIBABujNlni+3OzLPdqWQq/LCDOiyoK8LKRj4FomhBgbWVPXNfx +xPyPmJ+uh4bCV1dm1a4giHIgKlPqnPuOBNh4SF/Z79REmGMiiXP7IfvMu4DQi8K9 +4y4nnCVc93uvN5bRe4mywFhw0IqGd4sqVaVrSfdA124FTdbXng14HnVzbJncjpv+ +xr/ErDjbXy5AAbAGy3VbQsfxfbYMZ+Fc4fNzyJa2q+MQW8EzLlZOz2Frdty09lXB +fSVDzzbgwTsLT1PPmrjq7z50C28teA6ShJZhV8WHgbm3MH2CSb2ov0BAJNXA04Ip +sWbcKF9wBYYrHhddh2/qi9EQzJ4UVzf+ggRd3nkCggEAWcjyWjp4KRJcgJ65jwoz +S7uYS6s7MsGYCOOw5S9kNC/mZDhH+ddK8kdAY1RIqbrL74qHmSQ+kgge7epMn9Mp +W+/jXyDhm1t7wZ4jPRhisXTcF56ODpU9IR65PfTYPyvjHCkVbm+vOPt4ZxB9kNUD +3G3xt9bNLXvILrBB66lLqjYDWAzwBy751Tb3hKDZTPv8rAP7Uttt8NhTUi8BWXsR +/34fcRwlGWEAne9lrlIzQ2IofcXO+8fUgTa17ak+WJvVDINQKvGgAf4lHBFrixKP +l2ZqsC1a4bz1+nuym6hQlkJ9xUBjHNGTA+FNbpTcd5qDbx9/+lf09D6dq45DbBb3 +aQKCAQBrnFYocTm/fIeKo1n1kyF2ULkd6k984ztH8UyluXleSS1ShFFoo/x3vz35 +fsZNUggRnSe7/OBGZYquF/1roVULI1hKh4tbEmW4SWNeTFvwXKdRe6T7NnWSZtS/ +KtamA3lT2wtoEVOvfMo8M0hoFuRWdT2M0i+LKZQdRsq18XPLqdHt1kkSNcnPDERm +4gLQ8zXTf2fHrtZmyM8fc0GuTVwprPFeJkLtSPehkeXSTgb6rpyelX9NBUILwRgP +nw0+cbjFDFKaLnIrMFoVAAn/8DcnbbSt1TZhgNsMxY+GHWPBYW8SUi5nBmQQtmA7 +n3ju44acIPvJ9sWuZruVlWZGFaHm +-----END PRIVATE KEY----- diff --git a/redisinsight/api/test/test-runs/oss-clu-tls/certs/redisCA.crt b/redisinsight/api/test/test-runs/oss-clu-tls/certs/redisCA.crt new file mode 100644 index 0000000000..796fcb3e05 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-clu-tls/certs/redisCA.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFHzCCAwegAwIBAgIUKeAfHPO6uJBW+s8fY2cWKOc+DfgwDQYJKoZIhvcNAQEL +BQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTMy +NzI2WhcNMzExMDI2MTMyNzI2WjAfMQswCQYDVQQGEwJBVTEQMA4GA1UEAwwHZXhh +bXBsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANF9A4aeHru2fX1j +U+Bz9D2supYsMG64f+wXNrFPTMxPS/rdjNcqAWeVCknY7d8EO0uBf64Gm4ufQAPV +boINIdgoso9tGfl5LMaaiYq0aD5CK0wmU38pPbKA2Vr9bkrNIYLUFU6oPI7RJ5fL +Pl/vbvHyaXQKcDd5xxusAu3Ytrylq3WaLNWwhT//WRor4SU2qt9s06PiOgCABY+D +olMXI72gDaehRhnbOVXc6GadlHCsE5GHYJ3WcLLY0rGEdlwphcEG5TRVHGBiHOg/ +J0vsiuhwTLyRqQq5L6eFm33d4aRI9JLY8LlU5ywGiVoNl+fFdQr3ovWw7eObQSbg +BuOJhQBTpEmiPgiOC3kAUUrgT/uGS1x9RX+Wj0sY6zs+qOkfhFAcScXQBeZSLNT9 +RYAjZQOTtTQYVwH8NcF2MlwI3tb3qk2+2Xby4YfTHxp42B8IHkedwfFzrwfUDnNM +Cm3GSVtDGv/j4/7fp0oZZROpd5+h1wRhR/HO08rkpwuobo6xGRrrxqbdlsid3OB4 +Kk92Wl8reccxyr2a/7OlrWk284xpQI/nlU6a8bByJp2eTPYuNJFfJkrqua94YOJy +K4d4tLNbQ4X/5g12unGEHg8/HVNHJjCKiU2Gwxhxm50EqmgdgaboDmf+GuVF0tL1 +kGPbbjSrlt2pS+Tdza9EJfmAzVppAgMBAAGjUzBRMB0GA1UdDgQWBBQWqqhnKa+s +5ToC0TYKlIdfytda/jAfBgNVHSMEGDAWgBQWqqhnKa+s5ToC0TYKlIdfytda/jAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQATnfNftaIalbJmNHrq +7KIxyAAEimiIvci5i1f2X8ZB4XY1TWcf0eQ/+oJW9E2Kj83yGaAqKg1FFP8ugquG +pxzMyljTlQi3cWp58ByaYg2XMEyRY5FtbcdbHHJgdM2PSHsegWN/vfJw3H3J2673 +J6Kc/69IwJcE+aYDbLa/cnnRBn9AKHdtNoRmPH+s162oo8I86LQuG942Hx2CHCjt +ttkLwVBtsBKc5hRaPR+Psx68jS41iHJGHUwHNcPl7llBAQe8kKNg4GHJWT5vh7rd +rw4jAGCsoaE5Ol1HyRDprpdcC4o+eQbhMrjMcFzYduuCx1F96BlSXA2mQe+9lD08 +LzdS35ILmSqCTbtOcdHCmnjWp9fhl5mIrJ+I3G33QPHaHJXBfWGNidxjkibwdVxK +eNAOv4lEMCoVQ0occzNyUluJQUFJyvXtXWFSErRH6b78Gsc/AvPijPbSNuT8hRK9 +GC3yRYltDFXcr4+2lxJyoQoR6Y8399oaJm4U17fOIwlM+iI8dT8x+qsT8brw+5kk +oKu4jz8jfkZrUF8V8hIfAUc08IvMAmDwvIMeAjXFmDBEECxXBGRw2hTcY/53Nfdt +PRWzaL/YtKOy/9UfJQKs3Ihqte59/v3EJO9x9vTLmcpoCgh9piNVgD6OS38cOiEa +snS90+qMig9Gx3aJ+UvktWcp3Q== +-----END CERTIFICATE----- diff --git a/redisinsight/api/test/test-runs/oss-clu-tls/docker-compose.yml b/redisinsight/api/test/test-runs/oss-clu-tls/docker-compose.yml new file mode 100644 index 0000000000..2fc870c2a9 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-clu-tls/docker-compose.yml @@ -0,0 +1,32 @@ +version: "3.4" + +services: + test: + env_file: + - ./oss-clu-tls/.env + environment: + TEST_REDIS_HOST: "r1" + + redis: + build: &build ./oss-clu-tls + environment: + - 'REDIS_NODES=r1 r2 r3' + - 'REDIS_CLUSTER_REPLICAS=0' + - 'REDIS_CLUSTER_CREATOR=yes' + depends_on: + - r1 + - r2 + - r3 + + r1: + build: *build + environment: + - 'REDIS_NODES=r1 r2 r3' + r2: + build: *build + environment: + - 'REDIS_NODES=r1 r2 r3' + r3: + build: *build + environment: + - 'REDIS_NODES=r1 r2 r3' diff --git a/redisinsight/api/test/test-runs/oss-clu/.env b/redisinsight/api/test/test-runs/oss-clu/.env new file mode 100644 index 0000000000..06feb98933 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-clu/.env @@ -0,0 +1 @@ +TEST_REDIS_PORT=6379 diff --git a/redisinsight/api/test/test-runs/oss-clu/Dockerfile b/redisinsight/api/test/test-runs/oss-clu/Dockerfile new file mode 100644 index 0000000000..935c19bbd9 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-clu/Dockerfile @@ -0,0 +1,3 @@ +FROM bitnami/redis-cluster:6.0.8 + +ENV ALLOW_EMPTY_PASSWORD yes diff --git a/redisinsight/api/test/test-runs/oss-clu/docker-compose.yml b/redisinsight/api/test/test-runs/oss-clu/docker-compose.yml new file mode 100644 index 0000000000..9e98202a57 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-clu/docker-compose.yml @@ -0,0 +1,44 @@ +version: "3.4" + +services: + test: + env_file: + - ./oss-clu/.env + environment: + TEST_REDIS_HOST: "r1" + + redis: + build: &build ./oss-clu + environment: + - &nodes 'REDIS_NODES=r1 r2 r3 s1 s2 s3 s4 s5 s6' + - 'REDIS_CLUSTER_REPLICAS=2' + - 'REDIS_CLUSTER_CREATOR=yes' + depends_on: [r1, r2, r3, s1, s2, s3, s4, s5, s6] + + r1: + build: *build + environment: [*nodes] + r2: + build: *build + environment: [*nodes] + r3: + build: *build + environment: [*nodes] + s1: + build: *build + environment: [*nodes] + s2: + build: *build + environment: [*nodes] + s3: + build: *build + environment: [*nodes] + s4: + build: *build + environment: [*nodes] + s5: + build: *build + environment: [*nodes] + s6: + build: *build + environment: [*nodes] diff --git a/redisinsight/api/test/test-runs/oss-sent/.env b/redisinsight/api/test/test-runs/oss-sent/.env new file mode 100644 index 0000000000..014a3d7076 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-sent/.env @@ -0,0 +1,3 @@ +TEST_REDIS_PASSWORD=testpass +TEST_SENTINEL_MASTER_GROUP=primary1 +TEST_RTE_DISCOVERY_TYPE=SENTINEL diff --git a/redisinsight/api/test/test-runs/oss-sent/Dockerfile b/redisinsight/api/test/test-runs/oss-sent/Dockerfile new file mode 100644 index 0000000000..54dbc8fa28 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-sent/Dockerfile @@ -0,0 +1,15 @@ +FROM redis:5 + +ENV ALLOW_EMPTY_PASSWORD=yes + +ENV SENTINEL_QUORUM 2 +ENV SENTINEL_DOWN_AFTER 5000 +ENV SENTINEL_FAILOVER 10000 +ENV SENTINEL_PORT 26000 +ENV AUTH_PASS testpass +ENV REQUIREPASS="" + +COPY --chown=1001 sentinel.conf /etc/redis/sentinel.conf +COPY entrypoint.sh /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/redisinsight/api/test/test-runs/oss-sent/docker-compose.yml b/redisinsight/api/test/test-runs/oss-sent/docker-compose.yml new file mode 100644 index 0000000000..85b40150a8 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-sent/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3.4" + +services: + test: + env_file: + - ./oss-sent/.env + environment: + TEST_REDIS_HOST: redis + TEST_REDIS_PORT: 26379 + redis: + build: ./oss-sent + links: + - p1:p1 + depends_on: + - s1_1 + - s1_2 + - p1 + + p1: + image: &r redis:5 + s1_1: + image: *r + command: redis-server --slaveof p1 6379 + s1_2: + image: *r + command: redis-server --slaveof p1 6379 diff --git a/redisinsight/api/test/test-runs/oss-sent/entrypoint.sh b/redisinsight/api/test/test-runs/oss-sent/entrypoint.sh new file mode 100755 index 0000000000..1de920d159 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-sent/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +sed -i "s/\$SENTINEL_PORT/$SENTINEL_PORT/g" /etc/redis/sentinel.conf +sed -i "s/\$SENTINEL_QUORUM/$SENTINEL_QUORUM/g" /etc/redis/sentinel.conf +sed -i "s/\$SENTINEL_DOWN_AFTER/$SENTINEL_DOWN_AFTER/g" /etc/redis/sentinel.conf +sed -i "s/\$SENTINEL_FAILOVER/$SENTINEL_FAILOVER/g" /etc/redis/sentinel.conf +sed -i "s/\$AUTH_PASS/$AUTH_PASS/g" /etc/redis/sentinel.conf +sed -i "s/\$REQUIREPASS/$REQUIREPASS/g" /etc/redis/sentinel.conf + +exec redis-server /etc/redis/sentinel.conf --sentinel diff --git a/redisinsight/api/test/test-runs/oss-sent/sentinel.conf b/redisinsight/api/test/test-runs/oss-sent/sentinel.conf new file mode 100644 index 0000000000..9c220ca678 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-sent/sentinel.conf @@ -0,0 +1,9 @@ +port 26379 +dir /tmp +sentinel monitor primary1 p1 6379 $SENTINEL_QUORUM +sentinel down-after-milliseconds primary1 $SENTINEL_DOWN_AFTER +sentinel parallel-syncs primary1 1 +sentinel failover-timeout primary1 $SENTINEL_FAILOVER +sentinel auth-pass primary1 testpass + +requirepass "testpass" diff --git a/redisinsight/api/test/test-runs/oss-st-5-pass/.env b/redisinsight/api/test/test-runs/oss-st-5-pass/.env new file mode 100644 index 0000000000..d89987cf45 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-5-pass/.env @@ -0,0 +1 @@ +TEST_REDIS_PASSWORD=testpass diff --git a/redisinsight/api/test/test-runs/oss-st-5-pass/docker-compose.yml b/redisinsight/api/test/test-runs/oss-st-5-pass/docker-compose.yml new file mode 100644 index 0000000000..4fee97e589 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-5-pass/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3.4" + +services: + test: + env_file: + - ./oss-st-5-pass/.env + redis: + image: redis:5 + command: redis-server --requirepass testpass diff --git a/redisinsight/api/test/test-runs/oss-st-5/Dockerfile b/redisinsight/api/test/test-runs/oss-st-5/Dockerfile new file mode 100644 index 0000000000..f5e9b73a8e --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-5/Dockerfile @@ -0,0 +1,14 @@ +FROM redislabs/redisearch:1.6.15 as redisearch +FROM redislabs/rejson:1.0.8 as rejson + +FROM redis:5 + +# Install RediSearch 1.6.15 +COPY --from=redisearch /usr/lib/redis/modules/ /usr/lib/redis/modules/ + +# Install RedisJSON 1.0.8 +COPY --from=rejson /usr/lib/redis/modules/ /usr/lib/redis/modules/ + +COPY redis.conf /etc/redis.conf + +ENTRYPOINT [ "redis-server", "/etc/redis.conf" ] diff --git a/redisinsight/api/test/test-runs/oss-st-5/docker-compose.yml b/redisinsight/api/test/test-runs/oss-st-5/docker-compose.yml new file mode 100644 index 0000000000..6195f0326d --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-5/docker-compose.yml @@ -0,0 +1,5 @@ +version: "3.4" + +services: + redis: + build: ./oss-st-5 diff --git a/redisinsight/api/test/test-runs/oss-st-5/redis.conf b/redisinsight/api/test/test-runs/oss-st-5/redis.conf new file mode 100644 index 0000000000..6bfbbb393b --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-5/redis.conf @@ -0,0 +1,4 @@ +port 6379 + +loadmodule /usr/lib/redis/modules/redisearch.so +loadmodule /usr/lib/redis/modules/rejson.so diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls-auth/.env b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/.env new file mode 100644 index 0000000000..7ef84659e8 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/.env @@ -0,0 +1,3 @@ +TEST_REDIS_TLS_CA="-----BEGIN CERTIFICATE-----\nMIIFHzCCAwegAwIBAgIUKeAfHPO6uJBW+s8fY2cWKOc+DfgwDQYJKoZIhvcNAQEL\nBQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTMy\nNzI2WhcNMzExMDI2MTMyNzI2WjAfMQswCQYDVQQGEwJBVTEQMA4GA1UEAwwHZXhh\nbXBsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANF9A4aeHru2fX1j\nU+Bz9D2supYsMG64f+wXNrFPTMxPS/rdjNcqAWeVCknY7d8EO0uBf64Gm4ufQAPV\nboINIdgoso9tGfl5LMaaiYq0aD5CK0wmU38pPbKA2Vr9bkrNIYLUFU6oPI7RJ5fL\nPl/vbvHyaXQKcDd5xxusAu3Ytrylq3WaLNWwhT//WRor4SU2qt9s06PiOgCABY+D\nolMXI72gDaehRhnbOVXc6GadlHCsE5GHYJ3WcLLY0rGEdlwphcEG5TRVHGBiHOg/\nJ0vsiuhwTLyRqQq5L6eFm33d4aRI9JLY8LlU5ywGiVoNl+fFdQr3ovWw7eObQSbg\nBuOJhQBTpEmiPgiOC3kAUUrgT/uGS1x9RX+Wj0sY6zs+qOkfhFAcScXQBeZSLNT9\nRYAjZQOTtTQYVwH8NcF2MlwI3tb3qk2+2Xby4YfTHxp42B8IHkedwfFzrwfUDnNM\nCm3GSVtDGv/j4/7fp0oZZROpd5+h1wRhR/HO08rkpwuobo6xGRrrxqbdlsid3OB4\nKk92Wl8reccxyr2a/7OlrWk284xpQI/nlU6a8bByJp2eTPYuNJFfJkrqua94YOJy\nK4d4tLNbQ4X/5g12unGEHg8/HVNHJjCKiU2Gwxhxm50EqmgdgaboDmf+GuVF0tL1\nkGPbbjSrlt2pS+Tdza9EJfmAzVppAgMBAAGjUzBRMB0GA1UdDgQWBBQWqqhnKa+s\n5ToC0TYKlIdfytda/jAfBgNVHSMEGDAWgBQWqqhnKa+s5ToC0TYKlIdfytda/jAP\nBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQATnfNftaIalbJmNHrq\n7KIxyAAEimiIvci5i1f2X8ZB4XY1TWcf0eQ/+oJW9E2Kj83yGaAqKg1FFP8ugquG\npxzMyljTlQi3cWp58ByaYg2XMEyRY5FtbcdbHHJgdM2PSHsegWN/vfJw3H3J2673\nJ6Kc/69IwJcE+aYDbLa/cnnRBn9AKHdtNoRmPH+s162oo8I86LQuG942Hx2CHCjt\nttkLwVBtsBKc5hRaPR+Psx68jS41iHJGHUwHNcPl7llBAQe8kKNg4GHJWT5vh7rd\nrw4jAGCsoaE5Ol1HyRDprpdcC4o+eQbhMrjMcFzYduuCx1F96BlSXA2mQe+9lD08\nLzdS35ILmSqCTbtOcdHCmnjWp9fhl5mIrJ+I3G33QPHaHJXBfWGNidxjkibwdVxK\neNAOv4lEMCoVQ0occzNyUluJQUFJyvXtXWFSErRH6b78Gsc/AvPijPbSNuT8hRK9\nGC3yRYltDFXcr4+2lxJyoQoR6Y8399oaJm4U17fOIwlM+iI8dT8x+qsT8brw+5kk\noKu4jz8jfkZrUF8V8hIfAUc08IvMAmDwvIMeAjXFmDBEECxXBGRw2hTcY/53Nfdt\nPRWzaL/YtKOy/9UfJQKs3Ihqte59/v3EJO9x9vTLmcpoCgh9piNVgD6OS38cOiEa\nsnS90+qMig9Gx3aJ+UvktWcp3Q==\n-----END CERTIFICATE-----\n" +TEST_USER_TLS_CERT="-----BEGIN CERTIFICATE-----\nMIIEzTCCArWgAwIBAgIUALiX/81ndGTG8UCPzu8r4Ev2IhIwDQYJKoZIhvcNAQEL\nBQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTQz\nNTAzWhcNMzExMDI2MTQzNTAzWjANMQswCQYDVQQGEwJBVTCCAiIwDQYJKoZIhvcN\nAQEBBQADggIPADCCAgoCggIBAKOod8jpFXqjtNvl0FgIkg0fSZbzvh7jbI7TEUVQ\nmyeZxjmB3fZh5f6dxM7TZ048CUOeUeq3lemDqay+Moku0rL4PsFNe8z1C1zHuhf9\n4Qw/f7rMBIZ73L4Y/7cPWfjZbeme06+D7HMBZGTWGHZCWrqZQOwA3hKBjC3VY/a5\nz6oP78+w18WDpnavGwXwgCd1yTOwz3tVJUOcJdjGv3iwrHABcGVfxUEKTabP+p6V\nHA/+w4AlCloS57GQCh0RWCXMyfekv6MGBaqQa6GtOK5ScLJ1YSlJ6PRoK2N+shbw\nL/kQGlilgYBVGOQgNKd94+PwJgOCy72S7p9yF3ZTBB4/51Bwl7IV74Om/GmqzJMx\nxY9/PPaxKlOkP+dW41/IrcDULdh0jAfe9rKdFf9/9NWA37S68pKFpzRuRrpLqIwm\nBPtHvtLnTbhgmS/O1Rwmxqs8r+VA6D8+/drAor/KAcCwgRiYLvhvl4ABoqj4toEK\njCXAR/jeoLAb8HDBzkot4hhJPjMhQMYX9/HfdK4YX359EkHdsO/+R6+ImXb68DS5\nzh0028ktMM+KEhWSffSmU3imZOrH1/TQfSxfzuTHvyd0HXAHvzx+w1VWNK4fqU8O\ntDbMt1GAaatrfrqwP4qTjzLEqtlJLIjg4qgzpYCRUvgVdxyeii9o7IeYT8I6Penf\nQpAJAgMBAAGjEzARMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIB\nABb+A9C1AqstP5d2HXS/pIef1BNYAV8A/D2+nUpEXiautmjRZBRNwzBX2ffEZWV7\nEMkvqrRwz2ZgOJ4BRzJiDIYoF8dOhd0lc/0PqoR0xVzjOFDqd0ZcPHAjaY3UoBE7\njQSQ6ccc1tY5peNLAWCvRO6V9yhdV/SKGhveXGl/24MK9juwArnAitekCWZJQifT\nCFOJX5UvifrT8s0v0AqkycaNpkMvl0BAl4DRDJ3+EwZmzfOdATawyXBVXHt1Gz+N\niskPJAJsIjEdFYTjDUzwRN3bHFbTRXt2v1U18YIvMjvxq8MlITEC2lEW+3Xu90d3\naE/N9mLNJCgmZ2CGywWoaJlUXix2LTo5kT5coVVx0HK0tg5EcBua05qM3xO9Rgxv\nHkCnm/jMeN4oQ5o7h+q7UQja8mg1bjCzlt+RxqoA1snjglra/h5I8TTEhvSfxEy7\nh5Wiwne/TH/e8fN1IYRDvv602MNSZnAEPyG3Hc5xQOSGNpoKOZG7tpU+mRYIvlPe\nJgA5WNZ83y25JqSxF3kQuk7vrLByzEByqV3j+jIAQiHu/qIXwXUpkoV3L6A18yx/\nTbpQasr/bRFZKe83WlNl2ASAVyubal8ocmA0ua24/RV0I0VOCEXiIkl+pZ6e5Qn4\nL6Tryy5NxaEpUAZ9yv3P75PfNVQ3+vGYi3BLuhZUf/Dd\n-----END CERTIFICATE-----\n" +TEST_USER_TLS_KEY="-----BEGIN PRIVATE KEY-----\nMIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCjqHfI6RV6o7Tb\n5dBYCJINH0mW874e42yO0xFFUJsnmcY5gd32YeX+ncTO02dOPAlDnlHqt5Xpg6ms\nvjKJLtKy+D7BTXvM9Qtcx7oX/eEMP3+6zASGe9y+GP+3D1n42W3pntOvg+xzAWRk\n1hh2Qlq6mUDsAN4SgYwt1WP2uc+qD+/PsNfFg6Z2rxsF8IAndckzsM97VSVDnCXY\nxr94sKxwAXBlX8VBCk2mz/qelRwP/sOAJQpaEuexkAodEVglzMn3pL+jBgWqkGuh\nrTiuUnCydWEpSej0aCtjfrIW8C/5EBpYpYGAVRjkIDSnfePj8CYDgsu9ku6fchd2\nUwQeP+dQcJeyFe+DpvxpqsyTMcWPfzz2sSpTpD/nVuNfyK3A1C3YdIwH3vaynRX/\nf/TVgN+0uvKShac0bka6S6iMJgT7R77S5024YJkvztUcJsarPK/lQOg/Pv3awKK/\nygHAsIEYmC74b5eAAaKo+LaBCowlwEf43qCwG/Bwwc5KLeIYST4zIUDGF/fx33Su\nGF9+fRJB3bDv/keviJl2+vA0uc4dNNvJLTDPihIVkn30plN4pmTqx9f00H0sX87k\nx78ndB1wB788fsNVVjSuH6lPDrQ2zLdRgGmra366sD+Kk48yxKrZSSyI4OKoM6WA\nkVL4FXccnoovaOyHmE/COj3p30KQCQIDAQABAoICADAiwPiq9dJYjD2RXrJF8w9B\nAJgRoP3cznVDx3SnvLrtE8yeUfbB3LADH3vl2iC8r8zfqCBtVv6T5zgTyTFoQDi7\no1mfvKYP/QORCz87QRIlKyB6GWqky8xt9eiV71SuPxHT0Vdyaf15j1nJTvCZm63+\nnYXMy4SN7fkdJoXPKTFP9q0TyqMhkbie0Efy8P6qOj+l5aDU7lzwdIFKE88fx9g5\n1CE9BfuXWDeUPJagLNzXhhEO0/iiTtt/Djp2e4LCtTTNlEAS6V+9kqq/FEjRnqwe\nsjE+t/ILIZfmD+OHSdTr05P3OhvQ671Na69H69uDKuslcV+U8/KZ0CTRTgjHqvUZ\neLNC8BZfAk8IZx637/rSlqPmxyS/j35vdslebTbWV2KM7jXPqSb9YokdoJ6M0NZX\nIYiMK2reVzjy2YvX1Nhp4Xn68il10XVS4P9tFxyNWdTclCbuSlTfgc27ercQMMgY\nfe7/8+A/QhV8tdly8W3HwTmvkmmWRSTMziI+zQzZmYYlAWb33rQYfMoHs4tEf2u2\nRf0Oso56X73sc3ncnOFm+s5iwTeUH6EgF3ephJX4nR3canmtpy40nbXUJ+tAuaAj\nuo56KNlPxIHKf96o2LGXGTrgbH39f0MebWOq/7YjtCg6sUbwuyyG3afLTHHuss13\n5bTJ5gD3rsiGUWjfY3oBAoIBAQDRR/BnDw501Hky32/Vhlt7Ef1iplru+Fh0yQj7\n2DQ+U+L1Ir4Q67ESH8qDnjkyLP1a8BDNOIEEGp5dBb+OHb/rwdb+RZ7OCIzFCQ/d\nWR7m0ucuPBQwytQb7iXa9w0umZwoeTXEGP8aGe+bSBIHv8/em26rkSx0A1rxr2/O\n1ho8xxgBmOxL3NSCnv56JUu/W0vFq/7OfWQ19SOvFahp3TeqR1gkHe76teWv11Pj\n+RdiIIdCOifWChZPEdgMZD4rl1cs9QQb+n+WkRt/mZgtTIRQIe+we+vIha7TW46X\n6A1DjSxV4WUSXvv10heYYpZkKzpNG9YOhRB3bvyDkRy11XZ5AoIBAQDIMUETtoa9\nEFNY+uieZwJCTWrrB1njLLRZS3eCAKsVegHD0txLG8H5VMkyZQErRe52zR9QXWU/\nU80tIO5BTbP3ME2AbjJvMwuiEe1lBKlVnn2JSGjbtzUMa1QBvDRmBEZkr8OneMN6\np2tX3L3Vw8Xm/97rjkAgo3gQkqyDf6VZ4xvH2Wo405yMywcoifMZXo/PN9fI5V8S\nfi3XjHrHzaY4cucbdaezVb4Zd0xwl+c6Ifw6+VtmRyfCEHk8yvSkoKWqdxtD0p3a\n3e8txYoI/YZltAICZ2vjZPv05Ts/VwWVzaxUArYiUH+k6J+6yCavKWesmeac0vLG\nyN07gpRPPsIRAoIBADIp+UDqxf9REsAT+L2I2BK27DKiR3eyhZlwuruLRnKOLv+t\nVTu/ExGSFzvXSERzrkMG+jAG1D4El2MaxqCtFtzO+Na4H2mpePydwHTBMPwJH6rg\nccKES7VqLx6+SyWZYmn9K9sWVseN4fYpn1DGNHBad3ueb7ZbO4hlEfrVLTLWUjXH\nzxQcGcA5liv3FqIGozH9mTUrr0KTwPrtyRGfGgGx2jnGBwuHYEf26D/j7Cv0Ohew\n0u2mO1S2pT/LI2/VderrzBFcyQpxO9MpIOXyymBe0hJOkeTdzlsRPivBTrSbeT4Y\nqd5ucByrQEahkwTtq6rh+jw+vwSx0MtElEotoZkCggEAB8ujNRlOdd5E4JokpMZu\nGBbbqvtGTMpY24FMzgsonlV57B4x5drW2taqXwP/36eBea7TIVYBs02YF8HIhVJ5\nR47h9bZU0G+0bEM2c1CTJ3pceRQQwT2JG0qyor6pa6+O7izJ+aOCOSx7yZgW7FQL\nSMt96r5HUP4MltifTx+RWMa3NjkJId1boz/kr3dvt/UutGsARBpqcVXogxQ9U7p2\nVoxi43bZaOpV1LgIifngTysznzhGjt0Gd1Ac6HkevapjyReKQEHbU8KApc+jaGY2\n7Y7s5RsR4HD2PrsOa5D/7q1roHnajcuErO9CCQvyNa/vEZGMoV61hXgc5UxYah2P\ngQKCAQEAkzISMmGPyQT7t6F/P2dFmrotAUU8gsEaWhrlkS0AuREXv1p14I1OnQhS\neWU7I9qSG4NfslRi5WUnowyawQKYibShtJ9/tOWMTaEELVTDtPAIu2y9kcquiG2j\no34vfpByz0w1vhmd/hwcPAvBFV+oaGN6lPz9Pv9MlNBLJoMhCPdr3aBJJuThT1Ka\nJQ/RT0XfU7XXSC74x7JwoKB4bobVHdON09yielC6w9wq9anqD18nrz/4wBwWDhDE\nKPxeXVpnIZfhukmWxkBY8NLAOFEenS3f6D4wzuOD25mPRSJQTngh7w9XkZYzDnOo\niwa43+YOKJx4Qh4SeXLBc/Udm1eMTA==\n-----END PRIVATE KEY-----\n" diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls-auth/Dockerfile b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/Dockerfile new file mode 100644 index 0000000000..0cf6ec834f --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/Dockerfile @@ -0,0 +1,13 @@ +FROM bitnami/redis:6.0.8 + +ENV ALLOW_EMPTY_PASSWORD yes + +# TLS options +ENV REDIS_TLS_ENABLED yes +ENV REDIS_TLS_PORT 6379 +ENV REDIS_TLS_CERT_FILE /opt/bitnami/redis/certs/redis.crt +ENV REDIS_TLS_KEY_FILE /opt/bitnami/redis/certs/redis.key +ENV REDIS_TLS_CA_FILE /opt/bitnami/redis/certs/redisCA.crt +ENV REDIS_TLS_AUTH_CLIENTS yes + +COPY --chown=1001 ./certs /opt/bitnami/redis/certs/ diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redis.crt b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redis.crt new file mode 100644 index 0000000000..2761116425 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redis.crt @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEzTCCArWgAwIBAgIUALiX/81ndGTG8UCPzu8r4Ev2IhEwDQYJKoZIhvcNAQEL +BQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTMy +NzI3WhcNMzExMDI2MTMyNzI3WjANMQswCQYDVQQGEwJBVTCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAM4osMW/hBlde+/E20wP9X+zJ0AMD6OtfhqQ5brC +gbVs9mPccZ/8R0fj83YtZfnoEodMZ/7yUTeCCPMdprAezMU1KBf9EpZmTdWhpO3e +kESHcQdsKkqGtyjYF7dDahTmKt4a4aHlPH0DJLltB5HlbVabkzlo+3S8QaNwH5lY +yJTQIqiqVzs9oRLT76nZuJjsym0dNXE42rO3KCniI6kvJDmUzBD8Wc94iDExfy7q +qHyV7b2DCp1w7XP4yrQAFQ6kiVqNcfTTAO4MHNP54V2nZLPdOsUD5BsYY8hu0HDc +/PisZ9ZMcw7LMfpUd3dfA9zefQXPQsWJK20ZCNmdIFtwvIFUpYu/FEF3FWO83zeI +XkVZiuCOnoFvp8JIDvXgzXUBWzvYmxLqVSZAuabqU5pKRswDPLGlZTkHbuP2DiXD +LD5AsTnICpzYkUeERSZKf2qc/nTUk04W/7FUT/75ItVzZvu90mPJlmArB0j4zdAG +KwKo8v/cF1hA1YznhibxcUAA/Q/O3Y6CPQ7C3NeaGKcycgUxWoEY3Leno40ukijd +R0MvsaY7V0/up37fkPtH9rcCkZOGVT5Q4Ww9gVO3yseXVkxbJyzHV1tuwg6yY9wO +LOU2Bbmazkjlkb8a5OyQol2zZNJ0L3lvRWTildtGUTsBkeqI6HAedTTHJkhjHh/P +P0+TAgMBAAGjEzARMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIB +AKn+aH60zdd4ItMOhgd4BIok/u6S4NF0oe4kDwC/EU5C0hbuT6gLAn7ArHMeadue +OuSnhqIPfxNR9WCes9OU1O00WeCuWCE4XfNMMFN5iDFfLxO4Oy5Adt0T74hWbcy6 +h28TdcjrkJEr7HR59G5tQ8TW5gVB4a0WXDw0ob9DSxbFKZU1uZm9L/+MgB/SNCHL +GZSKt75Z/M10b9BTC3OG9swsoWvXEjR2ICiwzk+LxVf5K38faDyBrNJVglrpEUZz +gP60kL73qK0y1/i35UuP0yIJIy48XnDsSByN7eBVsNTGMW3CFLKWA4RVfnEHNUff +vsLHXZFYsUIPnPc5jksFwb/wKAe9JbCrgQPhBYaIYkRGiYt64C48r3boIIVoz9+1 +9Nq0Ik06fCzlI9APq2nzEiVeB7mDyZ692neu32QM6zRkYor+W8uI21YnRJWlOx7+ +x2GIh2EZnEYNvbpbvk/fV5AqkYOu9auRAkcKfME7dJ3Gwndl0YBOjE2DMTv6vIjS +dVuGXQCvlzkRAnPMh5MR5/bSUKVvBryXs9ecAMgoVXBVB+4tGWct5ziL+8qyNtgA +WJ2EWj3xtLlMwwQmLjRsCrZjL4liLJG8Yn8Ehfq1rRJREH2O8uYKCO1fdhuI0Y5S +iBPfqJi6QBHj7i01K9OpNUB7l+xAFLA3cBsegcm2GPoL +-----END CERTIFICATE----- diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redis.key b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redis.key new file mode 100644 index 0000000000..fb0777e3ea --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redis.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDOKLDFv4QZXXvv +xNtMD/V/sydADA+jrX4akOW6woG1bPZj3HGf/EdH4/N2LWX56BKHTGf+8lE3ggjz +HaawHszFNSgX/RKWZk3VoaTt3pBEh3EHbCpKhrco2Be3Q2oU5ireGuGh5Tx9AyS5 +bQeR5W1Wm5M5aPt0vEGjcB+ZWMiU0CKoqlc7PaES0++p2biY7MptHTVxONqztygp +4iOpLyQ5lMwQ/FnPeIgxMX8u6qh8le29gwqdcO1z+Mq0ABUOpIlajXH00wDuDBzT ++eFdp2Sz3TrFA+QbGGPIbtBw3Pz4rGfWTHMOyzH6VHd3XwPc3n0Fz0LFiSttGQjZ +nSBbcLyBVKWLvxRBdxVjvN83iF5FWYrgjp6Bb6fCSA714M11AVs72JsS6lUmQLmm +6lOaSkbMAzyxpWU5B27j9g4lwyw+QLE5yAqc2JFHhEUmSn9qnP501JNOFv+xVE/+ ++SLVc2b7vdJjyZZgKwdI+M3QBisCqPL/3BdYQNWM54Ym8XFAAP0Pzt2Ogj0OwtzX +mhinMnIFMVqBGNy3p6ONLpIo3UdDL7GmO1dP7qd+35D7R/a3ApGThlU+UOFsPYFT +t8rHl1ZMWycsx1dbbsIOsmPcDizlNgW5ms5I5ZG/GuTskKJds2TSdC95b0Vk4pXb +RlE7AZHqiOhwHnU0xyZIYx4fzz9PkwIDAQABAoICAHyc/+0oDHNAnK+bsGrTorNj +2S/Pmox3TChGuXYgKEM/79cA4vWvim6cDQe7/U4Hx1tdBeeHFSyWP06k96Kxm1kA +/pExedDLWfTt1kGqLE4gCGRSL2YI9CGOLRerei3TysmiOgygAeYWxlYG33KC2Ypm +U6F6IbS4LnzaQ19v2R6KiMim3j+CyyAUV2O1pO1bBCjcZPdhRGEpLu/SL3gOdLkR +hiAmSSstUjVaE+SKFvnnrmLFGN996SoWkoAnJJNLRXMk2GMCQCejzrEa8+ymSCqo +aOO5rGHsZjQ7N2dhTNALdmCEqW+hxz3nXKcdGbqiCbQ/Sb8ZYNR7M2xGm85p4Ka9 +0UK4cOM1VJPwz8hotSKAUmXnpbu73CsZi5HyzwZkk4FpcaYCYrCbGVmm1cIKEKI7 +8SN/oqgFdj4Ha9cemnu+RecQZouK+wPWtcILd2kstJl52TV952fVOrnXQDo6XCXB +fbs9IYN1hB6N79xv4L7Jj53hSRMeSNf70Ejkh1FXPOvmvFT12wy5JQdBBR5nnb4a +GEsMpGVe1k3bxjK7K263tLSH0UZ8dMgdSx1E4D1hT1K/gEwTSMOJ0E1R0M6SJmF2 +6TnZ0MbJWx6PbICmyZrd2agfTQrq6CgY1fWLGbQrtnwXtsUR7PiHarydXfs3V8g1 +xHnK1bItOBBMOMcWV93hAoIBAQD2xMceUfBUN0TeWrJ4izD3lUYxmWqjTg7kPcjJ +0VN8v3txGAcyPmbuz5AEzvdFjTispoZNB9QOrmsUVWTQDE5otnei9kHWzqJWYHg4 +USuUuAh8OJGCiepo8zHT3qHDNhKGtOAp5LC8TaOznUFr35zCBCOsvQfRUKrv5IOc +vCFjO07Xly8+M3qK7/UswRQ6480VlE2t1p+VNaORHdTDg2tes3/9owuiNmR/sPT8 +nIoe01LS7qmZoiB1vracaLcBf1Iwd7RvKg7mgFJzmowZUYxyX2YGK5qZ1h74In2X +55+qQnNW0RwPijopTv711pMhMKWl8i3ilcCfoeBXz8zCwFfbAoIBAQDV3wHAO7ic +MYP/Bm5jgIwlix1eOWY/yB+VqdYn2GmC49vTEIlIVlFRq0vZE06iUxs87BIV08zO +4w/iKXd7ktkuhphiEuU2yXA3LQPHpbSOW43RONbf4glFU/DlP/P6fiybbWj6+f7L +7Zbvtz5AW03Y4ZpagJTqOgVdJ0MdLnh9vZj6okGGV1fidKtG6hr7Z/oLhnl9aAZK +4vrvBZ//qz99vEVByiV5kRaJDulu+diBy4n6iBjzjHA5a9e7lY3sUBw3DMgb7kYs +JJPkCPdSxCYq4Ef3z/Eao0tyUuCzyznfCMGJA1gBdTpwDNDCTaXqkjR5nvsdE5k0 +IVQgFPtcOPCpAoIBABujNlni+3OzLPdqWQq/LCDOiyoK8LKRj4FomhBgbWVPXNfx +xPyPmJ+uh4bCV1dm1a4giHIgKlPqnPuOBNh4SF/Z79REmGMiiXP7IfvMu4DQi8K9 +4y4nnCVc93uvN5bRe4mywFhw0IqGd4sqVaVrSfdA124FTdbXng14HnVzbJncjpv+ +xr/ErDjbXy5AAbAGy3VbQsfxfbYMZ+Fc4fNzyJa2q+MQW8EzLlZOz2Frdty09lXB +fSVDzzbgwTsLT1PPmrjq7z50C28teA6ShJZhV8WHgbm3MH2CSb2ov0BAJNXA04Ip +sWbcKF9wBYYrHhddh2/qi9EQzJ4UVzf+ggRd3nkCggEAWcjyWjp4KRJcgJ65jwoz +S7uYS6s7MsGYCOOw5S9kNC/mZDhH+ddK8kdAY1RIqbrL74qHmSQ+kgge7epMn9Mp +W+/jXyDhm1t7wZ4jPRhisXTcF56ODpU9IR65PfTYPyvjHCkVbm+vOPt4ZxB9kNUD +3G3xt9bNLXvILrBB66lLqjYDWAzwBy751Tb3hKDZTPv8rAP7Uttt8NhTUi8BWXsR +/34fcRwlGWEAne9lrlIzQ2IofcXO+8fUgTa17ak+WJvVDINQKvGgAf4lHBFrixKP +l2ZqsC1a4bz1+nuym6hQlkJ9xUBjHNGTA+FNbpTcd5qDbx9/+lf09D6dq45DbBb3 +aQKCAQBrnFYocTm/fIeKo1n1kyF2ULkd6k984ztH8UyluXleSS1ShFFoo/x3vz35 +fsZNUggRnSe7/OBGZYquF/1roVULI1hKh4tbEmW4SWNeTFvwXKdRe6T7NnWSZtS/ +KtamA3lT2wtoEVOvfMo8M0hoFuRWdT2M0i+LKZQdRsq18XPLqdHt1kkSNcnPDERm +4gLQ8zXTf2fHrtZmyM8fc0GuTVwprPFeJkLtSPehkeXSTgb6rpyelX9NBUILwRgP +nw0+cbjFDFKaLnIrMFoVAAn/8DcnbbSt1TZhgNsMxY+GHWPBYW8SUi5nBmQQtmA7 +n3ju44acIPvJ9sWuZruVlWZGFaHm +-----END PRIVATE KEY----- diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redisCA.crt b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redisCA.crt new file mode 100644 index 0000000000..796fcb3e05 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redisCA.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFHzCCAwegAwIBAgIUKeAfHPO6uJBW+s8fY2cWKOc+DfgwDQYJKoZIhvcNAQEL +BQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTMy +NzI2WhcNMzExMDI2MTMyNzI2WjAfMQswCQYDVQQGEwJBVTEQMA4GA1UEAwwHZXhh +bXBsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANF9A4aeHru2fX1j +U+Bz9D2supYsMG64f+wXNrFPTMxPS/rdjNcqAWeVCknY7d8EO0uBf64Gm4ufQAPV +boINIdgoso9tGfl5LMaaiYq0aD5CK0wmU38pPbKA2Vr9bkrNIYLUFU6oPI7RJ5fL +Pl/vbvHyaXQKcDd5xxusAu3Ytrylq3WaLNWwhT//WRor4SU2qt9s06PiOgCABY+D +olMXI72gDaehRhnbOVXc6GadlHCsE5GHYJ3WcLLY0rGEdlwphcEG5TRVHGBiHOg/ +J0vsiuhwTLyRqQq5L6eFm33d4aRI9JLY8LlU5ywGiVoNl+fFdQr3ovWw7eObQSbg +BuOJhQBTpEmiPgiOC3kAUUrgT/uGS1x9RX+Wj0sY6zs+qOkfhFAcScXQBeZSLNT9 +RYAjZQOTtTQYVwH8NcF2MlwI3tb3qk2+2Xby4YfTHxp42B8IHkedwfFzrwfUDnNM +Cm3GSVtDGv/j4/7fp0oZZROpd5+h1wRhR/HO08rkpwuobo6xGRrrxqbdlsid3OB4 +Kk92Wl8reccxyr2a/7OlrWk284xpQI/nlU6a8bByJp2eTPYuNJFfJkrqua94YOJy +K4d4tLNbQ4X/5g12unGEHg8/HVNHJjCKiU2Gwxhxm50EqmgdgaboDmf+GuVF0tL1 +kGPbbjSrlt2pS+Tdza9EJfmAzVppAgMBAAGjUzBRMB0GA1UdDgQWBBQWqqhnKa+s +5ToC0TYKlIdfytda/jAfBgNVHSMEGDAWgBQWqqhnKa+s5ToC0TYKlIdfytda/jAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQATnfNftaIalbJmNHrq +7KIxyAAEimiIvci5i1f2X8ZB4XY1TWcf0eQ/+oJW9E2Kj83yGaAqKg1FFP8ugquG +pxzMyljTlQi3cWp58ByaYg2XMEyRY5FtbcdbHHJgdM2PSHsegWN/vfJw3H3J2673 +J6Kc/69IwJcE+aYDbLa/cnnRBn9AKHdtNoRmPH+s162oo8I86LQuG942Hx2CHCjt +ttkLwVBtsBKc5hRaPR+Psx68jS41iHJGHUwHNcPl7llBAQe8kKNg4GHJWT5vh7rd +rw4jAGCsoaE5Ol1HyRDprpdcC4o+eQbhMrjMcFzYduuCx1F96BlSXA2mQe+9lD08 +LzdS35ILmSqCTbtOcdHCmnjWp9fhl5mIrJ+I3G33QPHaHJXBfWGNidxjkibwdVxK +eNAOv4lEMCoVQ0occzNyUluJQUFJyvXtXWFSErRH6b78Gsc/AvPijPbSNuT8hRK9 +GC3yRYltDFXcr4+2lxJyoQoR6Y8399oaJm4U17fOIwlM+iI8dT8x+qsT8brw+5kk +oKu4jz8jfkZrUF8V8hIfAUc08IvMAmDwvIMeAjXFmDBEECxXBGRw2hTcY/53Nfdt +PRWzaL/YtKOy/9UfJQKs3Ihqte59/v3EJO9x9vTLmcpoCgh9piNVgD6OS38cOiEa +snS90+qMig9Gx3aJ+UvktWcp3Q== +-----END CERTIFICATE----- diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/user.crt b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/user.crt new file mode 100644 index 0000000000..ecd9b6f068 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/user.crt @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEzTCCArWgAwIBAgIUALiX/81ndGTG8UCPzu8r4Ev2IhIwDQYJKoZIhvcNAQEL +BQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTQz +NTAzWhcNMzExMDI2MTQzNTAzWjANMQswCQYDVQQGEwJBVTCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAKOod8jpFXqjtNvl0FgIkg0fSZbzvh7jbI7TEUVQ +myeZxjmB3fZh5f6dxM7TZ048CUOeUeq3lemDqay+Moku0rL4PsFNe8z1C1zHuhf9 +4Qw/f7rMBIZ73L4Y/7cPWfjZbeme06+D7HMBZGTWGHZCWrqZQOwA3hKBjC3VY/a5 +z6oP78+w18WDpnavGwXwgCd1yTOwz3tVJUOcJdjGv3iwrHABcGVfxUEKTabP+p6V +HA/+w4AlCloS57GQCh0RWCXMyfekv6MGBaqQa6GtOK5ScLJ1YSlJ6PRoK2N+shbw +L/kQGlilgYBVGOQgNKd94+PwJgOCy72S7p9yF3ZTBB4/51Bwl7IV74Om/GmqzJMx +xY9/PPaxKlOkP+dW41/IrcDULdh0jAfe9rKdFf9/9NWA37S68pKFpzRuRrpLqIwm +BPtHvtLnTbhgmS/O1Rwmxqs8r+VA6D8+/drAor/KAcCwgRiYLvhvl4ABoqj4toEK +jCXAR/jeoLAb8HDBzkot4hhJPjMhQMYX9/HfdK4YX359EkHdsO/+R6+ImXb68DS5 +zh0028ktMM+KEhWSffSmU3imZOrH1/TQfSxfzuTHvyd0HXAHvzx+w1VWNK4fqU8O +tDbMt1GAaatrfrqwP4qTjzLEqtlJLIjg4qgzpYCRUvgVdxyeii9o7IeYT8I6Penf +QpAJAgMBAAGjEzARMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIB +ABb+A9C1AqstP5d2HXS/pIef1BNYAV8A/D2+nUpEXiautmjRZBRNwzBX2ffEZWV7 +EMkvqrRwz2ZgOJ4BRzJiDIYoF8dOhd0lc/0PqoR0xVzjOFDqd0ZcPHAjaY3UoBE7 +jQSQ6ccc1tY5peNLAWCvRO6V9yhdV/SKGhveXGl/24MK9juwArnAitekCWZJQifT +CFOJX5UvifrT8s0v0AqkycaNpkMvl0BAl4DRDJ3+EwZmzfOdATawyXBVXHt1Gz+N +iskPJAJsIjEdFYTjDUzwRN3bHFbTRXt2v1U18YIvMjvxq8MlITEC2lEW+3Xu90d3 +aE/N9mLNJCgmZ2CGywWoaJlUXix2LTo5kT5coVVx0HK0tg5EcBua05qM3xO9Rgxv +HkCnm/jMeN4oQ5o7h+q7UQja8mg1bjCzlt+RxqoA1snjglra/h5I8TTEhvSfxEy7 +h5Wiwne/TH/e8fN1IYRDvv602MNSZnAEPyG3Hc5xQOSGNpoKOZG7tpU+mRYIvlPe +JgA5WNZ83y25JqSxF3kQuk7vrLByzEByqV3j+jIAQiHu/qIXwXUpkoV3L6A18yx/ +TbpQasr/bRFZKe83WlNl2ASAVyubal8ocmA0ua24/RV0I0VOCEXiIkl+pZ6e5Qn4 +L6Tryy5NxaEpUAZ9yv3P75PfNVQ3+vGYi3BLuhZUf/Dd +-----END CERTIFICATE----- diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/user.key b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/user.key new file mode 100644 index 0000000000..f201473517 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/user.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCjqHfI6RV6o7Tb +5dBYCJINH0mW874e42yO0xFFUJsnmcY5gd32YeX+ncTO02dOPAlDnlHqt5Xpg6ms +vjKJLtKy+D7BTXvM9Qtcx7oX/eEMP3+6zASGe9y+GP+3D1n42W3pntOvg+xzAWRk +1hh2Qlq6mUDsAN4SgYwt1WP2uc+qD+/PsNfFg6Z2rxsF8IAndckzsM97VSVDnCXY +xr94sKxwAXBlX8VBCk2mz/qelRwP/sOAJQpaEuexkAodEVglzMn3pL+jBgWqkGuh +rTiuUnCydWEpSej0aCtjfrIW8C/5EBpYpYGAVRjkIDSnfePj8CYDgsu9ku6fchd2 +UwQeP+dQcJeyFe+DpvxpqsyTMcWPfzz2sSpTpD/nVuNfyK3A1C3YdIwH3vaynRX/ +f/TVgN+0uvKShac0bka6S6iMJgT7R77S5024YJkvztUcJsarPK/lQOg/Pv3awKK/ +ygHAsIEYmC74b5eAAaKo+LaBCowlwEf43qCwG/Bwwc5KLeIYST4zIUDGF/fx33Su +GF9+fRJB3bDv/keviJl2+vA0uc4dNNvJLTDPihIVkn30plN4pmTqx9f00H0sX87k +x78ndB1wB788fsNVVjSuH6lPDrQ2zLdRgGmra366sD+Kk48yxKrZSSyI4OKoM6WA +kVL4FXccnoovaOyHmE/COj3p30KQCQIDAQABAoICADAiwPiq9dJYjD2RXrJF8w9B +AJgRoP3cznVDx3SnvLrtE8yeUfbB3LADH3vl2iC8r8zfqCBtVv6T5zgTyTFoQDi7 +o1mfvKYP/QORCz87QRIlKyB6GWqky8xt9eiV71SuPxHT0Vdyaf15j1nJTvCZm63+ +nYXMy4SN7fkdJoXPKTFP9q0TyqMhkbie0Efy8P6qOj+l5aDU7lzwdIFKE88fx9g5 +1CE9BfuXWDeUPJagLNzXhhEO0/iiTtt/Djp2e4LCtTTNlEAS6V+9kqq/FEjRnqwe +sjE+t/ILIZfmD+OHSdTr05P3OhvQ671Na69H69uDKuslcV+U8/KZ0CTRTgjHqvUZ +eLNC8BZfAk8IZx637/rSlqPmxyS/j35vdslebTbWV2KM7jXPqSb9YokdoJ6M0NZX +IYiMK2reVzjy2YvX1Nhp4Xn68il10XVS4P9tFxyNWdTclCbuSlTfgc27ercQMMgY +fe7/8+A/QhV8tdly8W3HwTmvkmmWRSTMziI+zQzZmYYlAWb33rQYfMoHs4tEf2u2 +Rf0Oso56X73sc3ncnOFm+s5iwTeUH6EgF3ephJX4nR3canmtpy40nbXUJ+tAuaAj +uo56KNlPxIHKf96o2LGXGTrgbH39f0MebWOq/7YjtCg6sUbwuyyG3afLTHHuss13 +5bTJ5gD3rsiGUWjfY3oBAoIBAQDRR/BnDw501Hky32/Vhlt7Ef1iplru+Fh0yQj7 +2DQ+U+L1Ir4Q67ESH8qDnjkyLP1a8BDNOIEEGp5dBb+OHb/rwdb+RZ7OCIzFCQ/d +WR7m0ucuPBQwytQb7iXa9w0umZwoeTXEGP8aGe+bSBIHv8/em26rkSx0A1rxr2/O +1ho8xxgBmOxL3NSCnv56JUu/W0vFq/7OfWQ19SOvFahp3TeqR1gkHe76teWv11Pj ++RdiIIdCOifWChZPEdgMZD4rl1cs9QQb+n+WkRt/mZgtTIRQIe+we+vIha7TW46X +6A1DjSxV4WUSXvv10heYYpZkKzpNG9YOhRB3bvyDkRy11XZ5AoIBAQDIMUETtoa9 +EFNY+uieZwJCTWrrB1njLLRZS3eCAKsVegHD0txLG8H5VMkyZQErRe52zR9QXWU/ +U80tIO5BTbP3ME2AbjJvMwuiEe1lBKlVnn2JSGjbtzUMa1QBvDRmBEZkr8OneMN6 +p2tX3L3Vw8Xm/97rjkAgo3gQkqyDf6VZ4xvH2Wo405yMywcoifMZXo/PN9fI5V8S +fi3XjHrHzaY4cucbdaezVb4Zd0xwl+c6Ifw6+VtmRyfCEHk8yvSkoKWqdxtD0p3a +3e8txYoI/YZltAICZ2vjZPv05Ts/VwWVzaxUArYiUH+k6J+6yCavKWesmeac0vLG +yN07gpRPPsIRAoIBADIp+UDqxf9REsAT+L2I2BK27DKiR3eyhZlwuruLRnKOLv+t +VTu/ExGSFzvXSERzrkMG+jAG1D4El2MaxqCtFtzO+Na4H2mpePydwHTBMPwJH6rg +ccKES7VqLx6+SyWZYmn9K9sWVseN4fYpn1DGNHBad3ueb7ZbO4hlEfrVLTLWUjXH +zxQcGcA5liv3FqIGozH9mTUrr0KTwPrtyRGfGgGx2jnGBwuHYEf26D/j7Cv0Ohew +0u2mO1S2pT/LI2/VderrzBFcyQpxO9MpIOXyymBe0hJOkeTdzlsRPivBTrSbeT4Y +qd5ucByrQEahkwTtq6rh+jw+vwSx0MtElEotoZkCggEAB8ujNRlOdd5E4JokpMZu +GBbbqvtGTMpY24FMzgsonlV57B4x5drW2taqXwP/36eBea7TIVYBs02YF8HIhVJ5 +R47h9bZU0G+0bEM2c1CTJ3pceRQQwT2JG0qyor6pa6+O7izJ+aOCOSx7yZgW7FQL +SMt96r5HUP4MltifTx+RWMa3NjkJId1boz/kr3dvt/UutGsARBpqcVXogxQ9U7p2 +Voxi43bZaOpV1LgIifngTysznzhGjt0Gd1Ac6HkevapjyReKQEHbU8KApc+jaGY2 +7Y7s5RsR4HD2PrsOa5D/7q1roHnajcuErO9CCQvyNa/vEZGMoV61hXgc5UxYah2P +gQKCAQEAkzISMmGPyQT7t6F/P2dFmrotAUU8gsEaWhrlkS0AuREXv1p14I1OnQhS +eWU7I9qSG4NfslRi5WUnowyawQKYibShtJ9/tOWMTaEELVTDtPAIu2y9kcquiG2j +o34vfpByz0w1vhmd/hwcPAvBFV+oaGN6lPz9Pv9MlNBLJoMhCPdr3aBJJuThT1Ka +JQ/RT0XfU7XXSC74x7JwoKB4bobVHdON09yielC6w9wq9anqD18nrz/4wBwWDhDE +KPxeXVpnIZfhukmWxkBY8NLAOFEenS3f6D4wzuOD25mPRSJQTngh7w9XkZYzDnOo +iwa43+YOKJx4Qh4SeXLBc/Udm1eMTA== +-----END PRIVATE KEY----- diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls-auth/docker-compose.yml b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/docker-compose.yml new file mode 100644 index 0000000000..f4d79dbb06 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls-auth/docker-compose.yml @@ -0,0 +1,10 @@ +version: "3.4" + +services: + test: + env_file: + - ./oss-st-6-tls-auth/.env + redis: + build: + context: ./oss-st-6-tls-auth + dockerfile: Dockerfile diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls/.env b/redisinsight/api/test/test-runs/oss-st-6-tls/.env new file mode 100644 index 0000000000..b10bb5b1ff --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls/.env @@ -0,0 +1 @@ +TEST_REDIS_TLS_CA="-----BEGIN CERTIFICATE-----\nMIIFHzCCAwegAwIBAgIUKeAfHPO6uJBW+s8fY2cWKOc+DfgwDQYJKoZIhvcNAQEL\nBQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTMy\nNzI2WhcNMzExMDI2MTMyNzI2WjAfMQswCQYDVQQGEwJBVTEQMA4GA1UEAwwHZXhh\nbXBsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANF9A4aeHru2fX1j\nU+Bz9D2supYsMG64f+wXNrFPTMxPS/rdjNcqAWeVCknY7d8EO0uBf64Gm4ufQAPV\nboINIdgoso9tGfl5LMaaiYq0aD5CK0wmU38pPbKA2Vr9bkrNIYLUFU6oPI7RJ5fL\nPl/vbvHyaXQKcDd5xxusAu3Ytrylq3WaLNWwhT//WRor4SU2qt9s06PiOgCABY+D\nolMXI72gDaehRhnbOVXc6GadlHCsE5GHYJ3WcLLY0rGEdlwphcEG5TRVHGBiHOg/\nJ0vsiuhwTLyRqQq5L6eFm33d4aRI9JLY8LlU5ywGiVoNl+fFdQr3ovWw7eObQSbg\nBuOJhQBTpEmiPgiOC3kAUUrgT/uGS1x9RX+Wj0sY6zs+qOkfhFAcScXQBeZSLNT9\nRYAjZQOTtTQYVwH8NcF2MlwI3tb3qk2+2Xby4YfTHxp42B8IHkedwfFzrwfUDnNM\nCm3GSVtDGv/j4/7fp0oZZROpd5+h1wRhR/HO08rkpwuobo6xGRrrxqbdlsid3OB4\nKk92Wl8reccxyr2a/7OlrWk284xpQI/nlU6a8bByJp2eTPYuNJFfJkrqua94YOJy\nK4d4tLNbQ4X/5g12unGEHg8/HVNHJjCKiU2Gwxhxm50EqmgdgaboDmf+GuVF0tL1\nkGPbbjSrlt2pS+Tdza9EJfmAzVppAgMBAAGjUzBRMB0GA1UdDgQWBBQWqqhnKa+s\n5ToC0TYKlIdfytda/jAfBgNVHSMEGDAWgBQWqqhnKa+s5ToC0TYKlIdfytda/jAP\nBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQATnfNftaIalbJmNHrq\n7KIxyAAEimiIvci5i1f2X8ZB4XY1TWcf0eQ/+oJW9E2Kj83yGaAqKg1FFP8ugquG\npxzMyljTlQi3cWp58ByaYg2XMEyRY5FtbcdbHHJgdM2PSHsegWN/vfJw3H3J2673\nJ6Kc/69IwJcE+aYDbLa/cnnRBn9AKHdtNoRmPH+s162oo8I86LQuG942Hx2CHCjt\nttkLwVBtsBKc5hRaPR+Psx68jS41iHJGHUwHNcPl7llBAQe8kKNg4GHJWT5vh7rd\nrw4jAGCsoaE5Ol1HyRDprpdcC4o+eQbhMrjMcFzYduuCx1F96BlSXA2mQe+9lD08\nLzdS35ILmSqCTbtOcdHCmnjWp9fhl5mIrJ+I3G33QPHaHJXBfWGNidxjkibwdVxK\neNAOv4lEMCoVQ0occzNyUluJQUFJyvXtXWFSErRH6b78Gsc/AvPijPbSNuT8hRK9\nGC3yRYltDFXcr4+2lxJyoQoR6Y8399oaJm4U17fOIwlM+iI8dT8x+qsT8brw+5kk\noKu4jz8jfkZrUF8V8hIfAUc08IvMAmDwvIMeAjXFmDBEECxXBGRw2hTcY/53Nfdt\nPRWzaL/YtKOy/9UfJQKs3Ihqte59/v3EJO9x9vTLmcpoCgh9piNVgD6OS38cOiEa\nsnS90+qMig9Gx3aJ+UvktWcp3Q==\n-----END CERTIFICATE-----\n" diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls/Dockerfile b/redisinsight/api/test/test-runs/oss-st-6-tls/Dockerfile new file mode 100644 index 0000000000..2e04013a7b --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls/Dockerfile @@ -0,0 +1,13 @@ +FROM bitnami/redis:6.2.4 + +ENV ALLOW_EMPTY_PASSWORD yes + +# TLS options +ENV REDIS_TLS_ENABLED yes +ENV REDIS_TLS_PORT 6379 +ENV REDIS_TLS_CERT_FILE /opt/bitnami/redis/certs/redis.crt +ENV REDIS_TLS_KEY_FILE /opt/bitnami/redis/certs/redis.key +ENV REDIS_TLS_CA_FILE /opt/bitnami/redis/certs/redisCA.crt +ENV REDIS_TLS_AUTH_CLIENTS no + +COPY --chown=1001 ./certs /opt/bitnami/redis/certs/ diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls/certs/redis.crt b/redisinsight/api/test/test-runs/oss-st-6-tls/certs/redis.crt new file mode 100644 index 0000000000..2761116425 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls/certs/redis.crt @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEzTCCArWgAwIBAgIUALiX/81ndGTG8UCPzu8r4Ev2IhEwDQYJKoZIhvcNAQEL +BQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTMy +NzI3WhcNMzExMDI2MTMyNzI3WjANMQswCQYDVQQGEwJBVTCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAM4osMW/hBlde+/E20wP9X+zJ0AMD6OtfhqQ5brC +gbVs9mPccZ/8R0fj83YtZfnoEodMZ/7yUTeCCPMdprAezMU1KBf9EpZmTdWhpO3e +kESHcQdsKkqGtyjYF7dDahTmKt4a4aHlPH0DJLltB5HlbVabkzlo+3S8QaNwH5lY +yJTQIqiqVzs9oRLT76nZuJjsym0dNXE42rO3KCniI6kvJDmUzBD8Wc94iDExfy7q +qHyV7b2DCp1w7XP4yrQAFQ6kiVqNcfTTAO4MHNP54V2nZLPdOsUD5BsYY8hu0HDc +/PisZ9ZMcw7LMfpUd3dfA9zefQXPQsWJK20ZCNmdIFtwvIFUpYu/FEF3FWO83zeI +XkVZiuCOnoFvp8JIDvXgzXUBWzvYmxLqVSZAuabqU5pKRswDPLGlZTkHbuP2DiXD +LD5AsTnICpzYkUeERSZKf2qc/nTUk04W/7FUT/75ItVzZvu90mPJlmArB0j4zdAG +KwKo8v/cF1hA1YznhibxcUAA/Q/O3Y6CPQ7C3NeaGKcycgUxWoEY3Leno40ukijd +R0MvsaY7V0/up37fkPtH9rcCkZOGVT5Q4Ww9gVO3yseXVkxbJyzHV1tuwg6yY9wO +LOU2Bbmazkjlkb8a5OyQol2zZNJ0L3lvRWTildtGUTsBkeqI6HAedTTHJkhjHh/P +P0+TAgMBAAGjEzARMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIB +AKn+aH60zdd4ItMOhgd4BIok/u6S4NF0oe4kDwC/EU5C0hbuT6gLAn7ArHMeadue +OuSnhqIPfxNR9WCes9OU1O00WeCuWCE4XfNMMFN5iDFfLxO4Oy5Adt0T74hWbcy6 +h28TdcjrkJEr7HR59G5tQ8TW5gVB4a0WXDw0ob9DSxbFKZU1uZm9L/+MgB/SNCHL +GZSKt75Z/M10b9BTC3OG9swsoWvXEjR2ICiwzk+LxVf5K38faDyBrNJVglrpEUZz +gP60kL73qK0y1/i35UuP0yIJIy48XnDsSByN7eBVsNTGMW3CFLKWA4RVfnEHNUff +vsLHXZFYsUIPnPc5jksFwb/wKAe9JbCrgQPhBYaIYkRGiYt64C48r3boIIVoz9+1 +9Nq0Ik06fCzlI9APq2nzEiVeB7mDyZ692neu32QM6zRkYor+W8uI21YnRJWlOx7+ +x2GIh2EZnEYNvbpbvk/fV5AqkYOu9auRAkcKfME7dJ3Gwndl0YBOjE2DMTv6vIjS +dVuGXQCvlzkRAnPMh5MR5/bSUKVvBryXs9ecAMgoVXBVB+4tGWct5ziL+8qyNtgA +WJ2EWj3xtLlMwwQmLjRsCrZjL4liLJG8Yn8Ehfq1rRJREH2O8uYKCO1fdhuI0Y5S +iBPfqJi6QBHj7i01K9OpNUB7l+xAFLA3cBsegcm2GPoL +-----END CERTIFICATE----- diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls/certs/redis.key b/redisinsight/api/test/test-runs/oss-st-6-tls/certs/redis.key new file mode 100644 index 0000000000..fb0777e3ea --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls/certs/redis.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQDOKLDFv4QZXXvv +xNtMD/V/sydADA+jrX4akOW6woG1bPZj3HGf/EdH4/N2LWX56BKHTGf+8lE3ggjz +HaawHszFNSgX/RKWZk3VoaTt3pBEh3EHbCpKhrco2Be3Q2oU5ireGuGh5Tx9AyS5 +bQeR5W1Wm5M5aPt0vEGjcB+ZWMiU0CKoqlc7PaES0++p2biY7MptHTVxONqztygp +4iOpLyQ5lMwQ/FnPeIgxMX8u6qh8le29gwqdcO1z+Mq0ABUOpIlajXH00wDuDBzT ++eFdp2Sz3TrFA+QbGGPIbtBw3Pz4rGfWTHMOyzH6VHd3XwPc3n0Fz0LFiSttGQjZ +nSBbcLyBVKWLvxRBdxVjvN83iF5FWYrgjp6Bb6fCSA714M11AVs72JsS6lUmQLmm +6lOaSkbMAzyxpWU5B27j9g4lwyw+QLE5yAqc2JFHhEUmSn9qnP501JNOFv+xVE/+ ++SLVc2b7vdJjyZZgKwdI+M3QBisCqPL/3BdYQNWM54Ym8XFAAP0Pzt2Ogj0OwtzX +mhinMnIFMVqBGNy3p6ONLpIo3UdDL7GmO1dP7qd+35D7R/a3ApGThlU+UOFsPYFT +t8rHl1ZMWycsx1dbbsIOsmPcDizlNgW5ms5I5ZG/GuTskKJds2TSdC95b0Vk4pXb +RlE7AZHqiOhwHnU0xyZIYx4fzz9PkwIDAQABAoICAHyc/+0oDHNAnK+bsGrTorNj +2S/Pmox3TChGuXYgKEM/79cA4vWvim6cDQe7/U4Hx1tdBeeHFSyWP06k96Kxm1kA +/pExedDLWfTt1kGqLE4gCGRSL2YI9CGOLRerei3TysmiOgygAeYWxlYG33KC2Ypm +U6F6IbS4LnzaQ19v2R6KiMim3j+CyyAUV2O1pO1bBCjcZPdhRGEpLu/SL3gOdLkR +hiAmSSstUjVaE+SKFvnnrmLFGN996SoWkoAnJJNLRXMk2GMCQCejzrEa8+ymSCqo +aOO5rGHsZjQ7N2dhTNALdmCEqW+hxz3nXKcdGbqiCbQ/Sb8ZYNR7M2xGm85p4Ka9 +0UK4cOM1VJPwz8hotSKAUmXnpbu73CsZi5HyzwZkk4FpcaYCYrCbGVmm1cIKEKI7 +8SN/oqgFdj4Ha9cemnu+RecQZouK+wPWtcILd2kstJl52TV952fVOrnXQDo6XCXB +fbs9IYN1hB6N79xv4L7Jj53hSRMeSNf70Ejkh1FXPOvmvFT12wy5JQdBBR5nnb4a +GEsMpGVe1k3bxjK7K263tLSH0UZ8dMgdSx1E4D1hT1K/gEwTSMOJ0E1R0M6SJmF2 +6TnZ0MbJWx6PbICmyZrd2agfTQrq6CgY1fWLGbQrtnwXtsUR7PiHarydXfs3V8g1 +xHnK1bItOBBMOMcWV93hAoIBAQD2xMceUfBUN0TeWrJ4izD3lUYxmWqjTg7kPcjJ +0VN8v3txGAcyPmbuz5AEzvdFjTispoZNB9QOrmsUVWTQDE5otnei9kHWzqJWYHg4 +USuUuAh8OJGCiepo8zHT3qHDNhKGtOAp5LC8TaOznUFr35zCBCOsvQfRUKrv5IOc +vCFjO07Xly8+M3qK7/UswRQ6480VlE2t1p+VNaORHdTDg2tes3/9owuiNmR/sPT8 +nIoe01LS7qmZoiB1vracaLcBf1Iwd7RvKg7mgFJzmowZUYxyX2YGK5qZ1h74In2X +55+qQnNW0RwPijopTv711pMhMKWl8i3ilcCfoeBXz8zCwFfbAoIBAQDV3wHAO7ic +MYP/Bm5jgIwlix1eOWY/yB+VqdYn2GmC49vTEIlIVlFRq0vZE06iUxs87BIV08zO +4w/iKXd7ktkuhphiEuU2yXA3LQPHpbSOW43RONbf4glFU/DlP/P6fiybbWj6+f7L +7Zbvtz5AW03Y4ZpagJTqOgVdJ0MdLnh9vZj6okGGV1fidKtG6hr7Z/oLhnl9aAZK +4vrvBZ//qz99vEVByiV5kRaJDulu+diBy4n6iBjzjHA5a9e7lY3sUBw3DMgb7kYs +JJPkCPdSxCYq4Ef3z/Eao0tyUuCzyznfCMGJA1gBdTpwDNDCTaXqkjR5nvsdE5k0 +IVQgFPtcOPCpAoIBABujNlni+3OzLPdqWQq/LCDOiyoK8LKRj4FomhBgbWVPXNfx +xPyPmJ+uh4bCV1dm1a4giHIgKlPqnPuOBNh4SF/Z79REmGMiiXP7IfvMu4DQi8K9 +4y4nnCVc93uvN5bRe4mywFhw0IqGd4sqVaVrSfdA124FTdbXng14HnVzbJncjpv+ +xr/ErDjbXy5AAbAGy3VbQsfxfbYMZ+Fc4fNzyJa2q+MQW8EzLlZOz2Frdty09lXB +fSVDzzbgwTsLT1PPmrjq7z50C28teA6ShJZhV8WHgbm3MH2CSb2ov0BAJNXA04Ip +sWbcKF9wBYYrHhddh2/qi9EQzJ4UVzf+ggRd3nkCggEAWcjyWjp4KRJcgJ65jwoz +S7uYS6s7MsGYCOOw5S9kNC/mZDhH+ddK8kdAY1RIqbrL74qHmSQ+kgge7epMn9Mp +W+/jXyDhm1t7wZ4jPRhisXTcF56ODpU9IR65PfTYPyvjHCkVbm+vOPt4ZxB9kNUD +3G3xt9bNLXvILrBB66lLqjYDWAzwBy751Tb3hKDZTPv8rAP7Uttt8NhTUi8BWXsR +/34fcRwlGWEAne9lrlIzQ2IofcXO+8fUgTa17ak+WJvVDINQKvGgAf4lHBFrixKP +l2ZqsC1a4bz1+nuym6hQlkJ9xUBjHNGTA+FNbpTcd5qDbx9/+lf09D6dq45DbBb3 +aQKCAQBrnFYocTm/fIeKo1n1kyF2ULkd6k984ztH8UyluXleSS1ShFFoo/x3vz35 +fsZNUggRnSe7/OBGZYquF/1roVULI1hKh4tbEmW4SWNeTFvwXKdRe6T7NnWSZtS/ +KtamA3lT2wtoEVOvfMo8M0hoFuRWdT2M0i+LKZQdRsq18XPLqdHt1kkSNcnPDERm +4gLQ8zXTf2fHrtZmyM8fc0GuTVwprPFeJkLtSPehkeXSTgb6rpyelX9NBUILwRgP +nw0+cbjFDFKaLnIrMFoVAAn/8DcnbbSt1TZhgNsMxY+GHWPBYW8SUi5nBmQQtmA7 +n3ju44acIPvJ9sWuZruVlWZGFaHm +-----END PRIVATE KEY----- diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls/certs/redisCA.crt b/redisinsight/api/test/test-runs/oss-st-6-tls/certs/redisCA.crt new file mode 100644 index 0000000000..796fcb3e05 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls/certs/redisCA.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFHzCCAwegAwIBAgIUKeAfHPO6uJBW+s8fY2cWKOc+DfgwDQYJKoZIhvcNAQEL +BQAwHzELMAkGA1UEBhMCQVUxEDAOBgNVBAMMB2V4YW1wbGUwHhcNMjExMDI4MTMy +NzI2WhcNMzExMDI2MTMyNzI2WjAfMQswCQYDVQQGEwJBVTEQMA4GA1UEAwwHZXhh +bXBsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANF9A4aeHru2fX1j +U+Bz9D2supYsMG64f+wXNrFPTMxPS/rdjNcqAWeVCknY7d8EO0uBf64Gm4ufQAPV +boINIdgoso9tGfl5LMaaiYq0aD5CK0wmU38pPbKA2Vr9bkrNIYLUFU6oPI7RJ5fL +Pl/vbvHyaXQKcDd5xxusAu3Ytrylq3WaLNWwhT//WRor4SU2qt9s06PiOgCABY+D +olMXI72gDaehRhnbOVXc6GadlHCsE5GHYJ3WcLLY0rGEdlwphcEG5TRVHGBiHOg/ +J0vsiuhwTLyRqQq5L6eFm33d4aRI9JLY8LlU5ywGiVoNl+fFdQr3ovWw7eObQSbg +BuOJhQBTpEmiPgiOC3kAUUrgT/uGS1x9RX+Wj0sY6zs+qOkfhFAcScXQBeZSLNT9 +RYAjZQOTtTQYVwH8NcF2MlwI3tb3qk2+2Xby4YfTHxp42B8IHkedwfFzrwfUDnNM +Cm3GSVtDGv/j4/7fp0oZZROpd5+h1wRhR/HO08rkpwuobo6xGRrrxqbdlsid3OB4 +Kk92Wl8reccxyr2a/7OlrWk284xpQI/nlU6a8bByJp2eTPYuNJFfJkrqua94YOJy +K4d4tLNbQ4X/5g12unGEHg8/HVNHJjCKiU2Gwxhxm50EqmgdgaboDmf+GuVF0tL1 +kGPbbjSrlt2pS+Tdza9EJfmAzVppAgMBAAGjUzBRMB0GA1UdDgQWBBQWqqhnKa+s +5ToC0TYKlIdfytda/jAfBgNVHSMEGDAWgBQWqqhnKa+s5ToC0TYKlIdfytda/jAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQATnfNftaIalbJmNHrq +7KIxyAAEimiIvci5i1f2X8ZB4XY1TWcf0eQ/+oJW9E2Kj83yGaAqKg1FFP8ugquG +pxzMyljTlQi3cWp58ByaYg2XMEyRY5FtbcdbHHJgdM2PSHsegWN/vfJw3H3J2673 +J6Kc/69IwJcE+aYDbLa/cnnRBn9AKHdtNoRmPH+s162oo8I86LQuG942Hx2CHCjt +ttkLwVBtsBKc5hRaPR+Psx68jS41iHJGHUwHNcPl7llBAQe8kKNg4GHJWT5vh7rd +rw4jAGCsoaE5Ol1HyRDprpdcC4o+eQbhMrjMcFzYduuCx1F96BlSXA2mQe+9lD08 +LzdS35ILmSqCTbtOcdHCmnjWp9fhl5mIrJ+I3G33QPHaHJXBfWGNidxjkibwdVxK +eNAOv4lEMCoVQ0occzNyUluJQUFJyvXtXWFSErRH6b78Gsc/AvPijPbSNuT8hRK9 +GC3yRYltDFXcr4+2lxJyoQoR6Y8399oaJm4U17fOIwlM+iI8dT8x+qsT8brw+5kk +oKu4jz8jfkZrUF8V8hIfAUc08IvMAmDwvIMeAjXFmDBEECxXBGRw2hTcY/53Nfdt +PRWzaL/YtKOy/9UfJQKs3Ihqte59/v3EJO9x9vTLmcpoCgh9piNVgD6OS38cOiEa +snS90+qMig9Gx3aJ+UvktWcp3Q== +-----END CERTIFICATE----- diff --git a/redisinsight/api/test/test-runs/oss-st-6-tls/docker-compose.yml b/redisinsight/api/test/test-runs/oss-st-6-tls/docker-compose.yml new file mode 100644 index 0000000000..2a1d917340 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6-tls/docker-compose.yml @@ -0,0 +1,10 @@ +version: "3.4" + +services: + test: + env_file: + - ./oss-st-6-tls/.env + redis: + build: + context: ./oss-st-6-tls + dockerfile: Dockerfile diff --git a/redisinsight/api/test/test-runs/oss-st-6/docker-compose.yml b/redisinsight/api/test/test-runs/oss-st-6/docker-compose.yml new file mode 100644 index 0000000000..7b681b7ab3 --- /dev/null +++ b/redisinsight/api/test/test-runs/oss-st-6/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3.4" + +services: + redis: +# todo: change back after redislabs/redismod image will be fixed +# image: redislabs/redismod + image: redis:6 diff --git a/redisinsight/api/test/test-runs/re-clu/.env b/redisinsight/api/test/test-runs/re-clu/.env new file mode 100644 index 0000000000..e434c46f46 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-clu/.env @@ -0,0 +1,4 @@ +TEST_RE_HOST=redis +TEST_RE_USER=demo@redislabs.com +TEST_RE_PASS=123456 +TEST_REDIS_PORT=12010 diff --git a/redisinsight/api/test/test-runs/re-clu/Dockerfile b/redisinsight/api/test/test-runs/re-clu/Dockerfile new file mode 100644 index 0000000000..136d43fbc3 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-clu/Dockerfile @@ -0,0 +1,18 @@ +FROM redislabs/redis:6.0.8-28.bionic + +# Change user to root to install pip +USER root +RUN set -ex \ + && apt-get update \ + && apt-get install -y python3-pip \ + && pip3 install requests +# Change user back to redislabs +USER redislabs + +# Set the env var to instruct RE to create a cluster on startup +ENV BOOTSTRAP_ACTION create_cluster +ENV BOOTSTRAP_CLUSTER_FQDN cluster.local + +COPY run_re_and_create_db.sh create_dbs.py cert.pem ./ + +ENTRYPOINT [ "bash", "./run_re_and_create_db.sh" ] diff --git a/redisinsight/api/test/test-runs/re-clu/README.md b/redisinsight/api/test/test-runs/re-clu/README.md new file mode 100644 index 0000000000..6b65582c5a --- /dev/null +++ b/redisinsight/api/test/test-runs/re-clu/README.md @@ -0,0 +1,18 @@ +# docker-redisenterprise-testdb +A Docker container that creates test databases on a Redis Enterprise cluster + + +## Databases + +Environment variable control which dbs are created. By default, no db is created. +- `CREATE_SIMPLE_DB`: Single-shard simple database on port 12000 +- `CREATE_CLUSTER_DB`: Database-clustering enabled, with 3 shards on port 12010 +- `CREATE_TLS_DB`: Single-shard TLS database on port 12443 +- `CREATE_TLS_MUTUAL_AUTH_DB`: Single-shard TLS client authentication enabled database on port 12465 +- `CREATE_MODULES_DB`: Single-shard db with modules: RedisGraph, RediSearch and RedisTimeSeries on port 12003 +- `CREATE_CRDB`: CRDT database on port 12005. `CRDB_INSTANCES` env var should also be set to a space-separated list of the FQDNs of participating clusters. + + +## References + +- [Redis Enterprise REST API Docs](https://storage.googleapis.com/rlecrestapi/rest-html/http_rest_api.html) diff --git a/redisinsight/api/test/test-runs/re-clu/cert.pem b/redisinsight/api/test/test-runs/re-clu/cert.pem new file mode 100644 index 0000000000..3dfb16abf7 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-clu/cert.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFfzCCA2egAwIBAgIURYhz7wsPwNGHxFoINaEB6ysJyEYwDQYJKoZIhvcNAQEL +BQAwTzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRgwFgYDVQQKDA9SZWRpc0xh +YnMsIEluYy4xGTAXBgNVBAMMEHJlZGlzaW5zaWdodC5jb20wHhcNMjAxMjI4MTA1 +NzU1WhcNMjExMjI4MTA1NzU1WjBPMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0Ex +GDAWBgNVBAoMD1JlZGlzTGFicywgSW5jLjEZMBcGA1UEAwwQcmVkaXNpbnNpZ2h0 +LmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANS1OFxNjFn1JQJO +HounllmdHA0hMCoZ5DGO1rur+ppZBZFh9Z4NT01neEVMi9vZkvsmeGQ6xwOibbGv +EClNXrFS/pqBg91AdGJzz6lH08VHgAtu+2P6kDewTXQyN+Gu+3qlss4t3di9jQAd +oYXkbPT5ZPnaJtkWq+rrw8P7hCC0OARETzysg/IVukyYYIGjgpeCOWOWeF4oGurO +EQtZnuVncMC4ZvobGlGtXk0Rk46j1uwLDNgovLkajnMHViGxS/kCOSZB2UJvhve/ +YmKK14kxc/mFoNu1+ING5bGEcprVUe8wKXb+TuTRp2YxIB1GIG8+vQwcYFS5l0kH +BsRGwBKS2ESaSQ1eSyIVd2wdXVHqlILlmy2Zvi9DM/kMX/OtoBjIDhWx9mStTxtz +DjHIooT/FeFQzC2ah1bP+/KYabCHScEXpXxubpK9saXLtj4Vk/RcfZ42+0eeVxBf +Dttln7MHP79VyyCZpT9OSu8q4qU6dVDlz3fczC6fkE6b2kPVQnSLz9Wmr47syawg +Argv96d6wcNiiNzOyHZNCaxHwsVFx0zJOuRiyMwJp4JvrAb2glkKgCzVNjCMO+8v +HuXv6TTUHKvLwqnuqe04VRQazIDUPzQma+whdgIMkAJBanm7U+fmZ/LX0d1XLCMv +E5k/zzu9uOXUNM46Pvz20Rko8W4fAgMBAAGjUzBRMB0GA1UdDgQWBBSbd9FdsIfw +AUWS9kE93p5MGmv4WTAfBgNVHSMEGDAWgBSbd9FdsIfwAUWS9kE93p5MGmv4WTAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQCvXOzCaEGf6xoA8A7S +U5GXv5rGof1TW9+FogiUc1GyvEJJa4spo2MEASBCkqN0e5ac4tE3XiMsPXgoMCNt +XFRa71rI1a61an+02+i5hgG6fBTftTLRaMOWKXswiF/GN+Jc2aYPNy9KZtwxowre +g28a47vlAUT3O4ZdpdR4TVD6Zvy/EtA/TfuP9XPBA1TaAp6jEUArZFU3H0VM6nn3 +1GKH8rxqv54jLovA7ASs6CKU8PtgU0RDypB69lYVWjbVdrSA5Jh6o54N60tNizCk +LzaEbSGijI6qeKwouFoqKqx+Kr9aYPnGnA1rJXWVvI1Z8XIsWmb9fFnl9nPspqxF +A6vuvc2W9TIFLvwZIe/QC4kKq07rA7zCiF/dsjqU1VdeoJZXp0109t7Ua0tlUO/J +XIIY8sQHyJ2kZAiL9ghCHvgO5ciezVvu6ru1E6M8FD0OaopPa2jM4V562sZ/Ztdi +7IPlQ160zsHuYq3Q4uYNalWJ7gLaxbXFaok/fZo39GRVzjk/FN8zuXuGjMchhoaB +XrWDF0H6NM92cvH/8cgSg7JXvBlCdGqV9XPnqYm312MqeFZ9Lg2H8wTfHuAzNrOZ +eV1Cp5mBepvYtsFX3ATdDg7+QgAVhLdnAyUmMNY2cW1GTdfQV4L3fIm0S7RKayC6 +eeFbQeTv19tELZiXhalV3YuJMQ== +-----END CERTIFICATE----- diff --git a/redisinsight/api/test/test-runs/re-clu/create_dbs.py b/redisinsight/api/test/test-runs/re-clu/create_dbs.py new file mode 100644 index 0000000000..71b306ff86 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-clu/create_dbs.py @@ -0,0 +1,218 @@ +import os +import pprint +import subprocess + +import requests + + +# Suppress "Unverified HTTPS request" warnings +# See https://github.com/influxdata/influxdb-python/issues/240#issuecomment-140003499 +# pylint: disable=no-member +requests.packages.urllib3.disable_warnings() + + +CREATE_SIMPLE_DB = bool(os.environ.get("CREATE_SIMPLE_DB", "")) +CREATE_CLUSTER_DB = bool(os.environ.get("CREATE_CLUSTER_DB", "")) +CREATE_TLS_DB = bool(os.environ.get("CREATE_TLS_DB", "")) +CREATE_TLS_MUTUAL_AUTH_DB = bool(os.environ.get("CREATE_TLS_MUTUAL_AUTH_DB", "")) +CREATE_MODULES_DB = bool(os.environ.get("CREATE_MODULES_DB", "")) +CREATE_CRDB = bool(os.environ.get("CREATE_CRDB", "")) +CRDB_INSTANCES = os.environ.get("CRDB_INSTANCES", "") + + +USERNAME = 'demo@redislabs.com' +PASSWORD = '123456' + + +RLEC_API_BASE_URL = 'https://localhost:9443/v1' + + +COMMON_REQ_PARAMS = dict(auth=(USERNAME, PASSWORD), + verify=False,) + + +def get_module_data() -> dict: + """ + Returns a dict of module name to a dict containing: + - module_id + - module_name + - module_args + - semantic_version + """ + resp = requests.get(url=f'{RLEC_API_BASE_URL}/modules', **COMMON_REQ_PARAMS) + if not resp.ok: + raise Exception(f"Failed to get modules info: {resp.status_code}: {resp.text}") + data = resp.json() + module_dict = {} + for m in data: + module_dict[m['module_name']] = { + "module_id": m["uid"], + "module_name": m["module_name"], + "module_args": m["command_line_args"], + "semantic_version": m["semantic_version"], + } + return module_dict + + +def create_db(body: dict) -> dict: + """ + Create a bdb and return the response from the API. + """ + resp = requests.post(url=f'{RLEC_API_BASE_URL}/bdbs', + json=body, + **COMMON_REQ_PARAMS) + if not resp.ok: + raise Exception(f"Failed to create db: {resp.status_code}: {resp.text}") + data = resp.json() + return data + + +def create_simple_db() -> dict: + body = { + "name": "testdb", + "type": "redis", + "memory_size": 100000, + "port": 12000 + } + return create_db(body) + + +def create_cluster_db() -> dict: + body = { + "name": "testdb", + "type": "redis", + "memory_size": 1024* 1024 * 1024, # 1GB + "port": 12010, + "sharding": True, + "shards_count": 3, + "proxy_policy": "all-master-shards", + "oss_cluster": True, +# "oss_sharding": True, + # Default OSS Redis Cluster-like hashing policy. + # These regexes are taken from the RLEC REST API docs: + # https://storage.googleapis.com/rlecrestapi/rest-html/http_rest_api.html#bdb (see the 'shard_key_regex' attribute) + "shard_key_regex": [ + {"regex": ".*\\{(?.*)\\}.*" }, + {"regex": "(?.*)" } + ], + } + return create_db(body) + + +def create_tls_db() -> dict: + body = { + "name": "testtlsdb", + "type": "redis", + "memory_size": 100000, + "port": 12443, + "tls_mode": "enabled", + "enforce_client_authentication": "disabled" + } + return create_db(body) + + +def create_tls_mutual_auth_db() -> dict: + with open('./cert.pem') as f: + cert_str = f.read() + body = { + "name": "testtlsclientauthdb", + "type": "redis", + "memory_size": 100000, + "port": 12465, + "tls_mode": "enabled", + "enforce_client_authentication": "enabled", + "authentication_ssl_client_certs": [{ + "client_cert": cert_str, + }] + } + return create_db(body) + + +def create_modules_db(module_info: dict) -> dict: + body = { + "name": "modulesdb", + "type": "redis", + "memory_size": 100000, + "port": 12003, + "module_list": [ + module_info['ft'], + module_info['graph'], + module_info['timeseries'], + ] + } + return create_db(body) + + +def create_crdb() -> dict: + cluster_fqdns = CRDB_INSTANCES.split() + assert len(cluster_fqdns) >= 2, f"At least two clusters are needed for a CRDB, got {cluster_fqdns}" + crdb_cli_instances_args = (f"--instance fqdn={fqdn},username={USERNAME},password={PASSWORD}" + for fqdn in cluster_fqdns) + crdb_cli_instances_args = " ".join(crdb_cli_instances_args) + crdb_cli_command = f"/opt/redislabs/bin/crdb-cli crdb create --name mycrdb --memory-size 10mb --port 12005 --replication false --shards-count 1 {crdb_cli_instances_args}" + print("Running the following command:") + print(crdb_cli_command) + subprocess.run(crdb_cli_command.split()) + + +def main(): + + if CREATE_SIMPLE_DB: + print("Creating simple db...") + bdb = create_simple_db() + print("done") + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping simple db") + + if CREATE_CLUSTER_DB: + print("Creating cluster db...") + bdb = create_cluster_db() + print("done") + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping cluster db") + + if CREATE_TLS_DB: + print("Creating TLS db...") + bdb = create_tls_db() + print("done") + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping TLS db") + + if CREATE_TLS_MUTUAL_AUTH_DB: + print("Creating TLS mutual auth db...") + bdb = create_tls_mutual_auth_db() + print("done") + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping TLS mutual auth db") + + if CREATE_MODULES_DB: + print("Getting modules info...") + module_info = get_module_data() + print('done') + print("Creating modules db...") + bdb = create_modules_db(module_info) + print('done') + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping modules db") + + if CREATE_CRDB: + print("Creating CRDB...") + create_crdb() + print('done') + print("\n\n") + else: + print("Skipping CRDB") + + +if __name__ == '__main__': + main() diff --git a/redisinsight/api/test/test-runs/re-clu/docker-compose.yml b/redisinsight/api/test/test-runs/re-clu/docker-compose.yml new file mode 100644 index 0000000000..b0e04aeb61 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-clu/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.4" + +services: + test: + env_file: + - ./re-clu/.env + command: ['./wait-for-it.sh', 'redis:12010', '-s', '-t', '120', '--', 'yarn', 'test:api:ci:cov'] + redis: + build: ./re-clu + cap_add: + - sys_resource + environment: + - CREATE_CLUSTER_DB=1 +# ports: +# - 12010:12010 +# - 8443:8443 +# - 9443:9443 diff --git a/redisinsight/api/test/test-runs/re-clu/run_re_and_create_db.sh b/redisinsight/api/test/test-runs/re-clu/run_re_and_create_db.sh new file mode 100644 index 0000000000..1f8c28f883 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-clu/run_re_and_create_db.sh @@ -0,0 +1,39 @@ +#! /bin/bash + +set -e + +# enable job control +set -m + +/opt/start.sh & + +# This command queries the REST API and outputs the status code +CURL_CMD="curl --silent --fail --output /dev/null -i -w %{http_code} -u demo@redislabs.com:123456 -k https://localhost:9443/v1/nodes" + +# Wait to get 2 consecutive 200 responses from the REST API +while true +do + echo yay $CURL_CMD + CURL_CMD_OUTPUT=$($CURL_CMD || true) + if [ $CURL_CMD_OUTPUT == "200" ] + then + echo "Got 200 response, trying again in 5 seconds to verify..." + sleep 5 + if [ $($CURL_CMD || true) == "200" ] + then + echo "Got 200 response after 5 seconds again, proceeding..." + break + fi + else + echo "Did not get 200 response, got $CURL_CMD_OUTPUT, trying again in 10 seconds..." + sleep 10 + fi +done + +echo "Running Python script to create databases..." +python3 create_dbs.py + + +# now we bring the primary process back into the foreground +# and leave it there +fg diff --git a/redisinsight/api/test/test-runs/re-st/.env b/redisinsight/api/test/test-runs/re-st/.env new file mode 100644 index 0000000000..83eaa89c3a --- /dev/null +++ b/redisinsight/api/test/test-runs/re-st/.env @@ -0,0 +1,4 @@ +TEST_RE_HOST=redis +TEST_RE_USER=demo@redislabs.com +TEST_RE_PASS=123456 +TEST_REDIS_PORT=12000 diff --git a/redisinsight/api/test/test-runs/re-st/Dockerfile b/redisinsight/api/test/test-runs/re-st/Dockerfile new file mode 100644 index 0000000000..136d43fbc3 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-st/Dockerfile @@ -0,0 +1,18 @@ +FROM redislabs/redis:6.0.8-28.bionic + +# Change user to root to install pip +USER root +RUN set -ex \ + && apt-get update \ + && apt-get install -y python3-pip \ + && pip3 install requests +# Change user back to redislabs +USER redislabs + +# Set the env var to instruct RE to create a cluster on startup +ENV BOOTSTRAP_ACTION create_cluster +ENV BOOTSTRAP_CLUSTER_FQDN cluster.local + +COPY run_re_and_create_db.sh create_dbs.py cert.pem ./ + +ENTRYPOINT [ "bash", "./run_re_and_create_db.sh" ] diff --git a/redisinsight/api/test/test-runs/re-st/README.md b/redisinsight/api/test/test-runs/re-st/README.md new file mode 100644 index 0000000000..6b65582c5a --- /dev/null +++ b/redisinsight/api/test/test-runs/re-st/README.md @@ -0,0 +1,18 @@ +# docker-redisenterprise-testdb +A Docker container that creates test databases on a Redis Enterprise cluster + + +## Databases + +Environment variable control which dbs are created. By default, no db is created. +- `CREATE_SIMPLE_DB`: Single-shard simple database on port 12000 +- `CREATE_CLUSTER_DB`: Database-clustering enabled, with 3 shards on port 12010 +- `CREATE_TLS_DB`: Single-shard TLS database on port 12443 +- `CREATE_TLS_MUTUAL_AUTH_DB`: Single-shard TLS client authentication enabled database on port 12465 +- `CREATE_MODULES_DB`: Single-shard db with modules: RedisGraph, RediSearch and RedisTimeSeries on port 12003 +- `CREATE_CRDB`: CRDT database on port 12005. `CRDB_INSTANCES` env var should also be set to a space-separated list of the FQDNs of participating clusters. + + +## References + +- [Redis Enterprise REST API Docs](https://storage.googleapis.com/rlecrestapi/rest-html/http_rest_api.html) diff --git a/redisinsight/api/test/test-runs/re-st/cert.pem b/redisinsight/api/test/test-runs/re-st/cert.pem new file mode 100644 index 0000000000..3dfb16abf7 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-st/cert.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFfzCCA2egAwIBAgIURYhz7wsPwNGHxFoINaEB6ysJyEYwDQYJKoZIhvcNAQEL +BQAwTzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRgwFgYDVQQKDA9SZWRpc0xh +YnMsIEluYy4xGTAXBgNVBAMMEHJlZGlzaW5zaWdodC5jb20wHhcNMjAxMjI4MTA1 +NzU1WhcNMjExMjI4MTA1NzU1WjBPMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0Ex +GDAWBgNVBAoMD1JlZGlzTGFicywgSW5jLjEZMBcGA1UEAwwQcmVkaXNpbnNpZ2h0 +LmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANS1OFxNjFn1JQJO +HounllmdHA0hMCoZ5DGO1rur+ppZBZFh9Z4NT01neEVMi9vZkvsmeGQ6xwOibbGv +EClNXrFS/pqBg91AdGJzz6lH08VHgAtu+2P6kDewTXQyN+Gu+3qlss4t3di9jQAd +oYXkbPT5ZPnaJtkWq+rrw8P7hCC0OARETzysg/IVukyYYIGjgpeCOWOWeF4oGurO +EQtZnuVncMC4ZvobGlGtXk0Rk46j1uwLDNgovLkajnMHViGxS/kCOSZB2UJvhve/ +YmKK14kxc/mFoNu1+ING5bGEcprVUe8wKXb+TuTRp2YxIB1GIG8+vQwcYFS5l0kH +BsRGwBKS2ESaSQ1eSyIVd2wdXVHqlILlmy2Zvi9DM/kMX/OtoBjIDhWx9mStTxtz +DjHIooT/FeFQzC2ah1bP+/KYabCHScEXpXxubpK9saXLtj4Vk/RcfZ42+0eeVxBf +Dttln7MHP79VyyCZpT9OSu8q4qU6dVDlz3fczC6fkE6b2kPVQnSLz9Wmr47syawg +Argv96d6wcNiiNzOyHZNCaxHwsVFx0zJOuRiyMwJp4JvrAb2glkKgCzVNjCMO+8v +HuXv6TTUHKvLwqnuqe04VRQazIDUPzQma+whdgIMkAJBanm7U+fmZ/LX0d1XLCMv +E5k/zzu9uOXUNM46Pvz20Rko8W4fAgMBAAGjUzBRMB0GA1UdDgQWBBSbd9FdsIfw +AUWS9kE93p5MGmv4WTAfBgNVHSMEGDAWgBSbd9FdsIfwAUWS9kE93p5MGmv4WTAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQCvXOzCaEGf6xoA8A7S +U5GXv5rGof1TW9+FogiUc1GyvEJJa4spo2MEASBCkqN0e5ac4tE3XiMsPXgoMCNt +XFRa71rI1a61an+02+i5hgG6fBTftTLRaMOWKXswiF/GN+Jc2aYPNy9KZtwxowre +g28a47vlAUT3O4ZdpdR4TVD6Zvy/EtA/TfuP9XPBA1TaAp6jEUArZFU3H0VM6nn3 +1GKH8rxqv54jLovA7ASs6CKU8PtgU0RDypB69lYVWjbVdrSA5Jh6o54N60tNizCk +LzaEbSGijI6qeKwouFoqKqx+Kr9aYPnGnA1rJXWVvI1Z8XIsWmb9fFnl9nPspqxF +A6vuvc2W9TIFLvwZIe/QC4kKq07rA7zCiF/dsjqU1VdeoJZXp0109t7Ua0tlUO/J +XIIY8sQHyJ2kZAiL9ghCHvgO5ciezVvu6ru1E6M8FD0OaopPa2jM4V562sZ/Ztdi +7IPlQ160zsHuYq3Q4uYNalWJ7gLaxbXFaok/fZo39GRVzjk/FN8zuXuGjMchhoaB +XrWDF0H6NM92cvH/8cgSg7JXvBlCdGqV9XPnqYm312MqeFZ9Lg2H8wTfHuAzNrOZ +eV1Cp5mBepvYtsFX3ATdDg7+QgAVhLdnAyUmMNY2cW1GTdfQV4L3fIm0S7RKayC6 +eeFbQeTv19tELZiXhalV3YuJMQ== +-----END CERTIFICATE----- diff --git a/redisinsight/api/test/test-runs/re-st/create_dbs.py b/redisinsight/api/test/test-runs/re-st/create_dbs.py new file mode 100644 index 0000000000..422f57b832 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-st/create_dbs.py @@ -0,0 +1,215 @@ +import os +import pprint +import subprocess + +import requests + + +# Suppress "Unverified HTTPS request" warnings +# See https://github.com/influxdata/influxdb-python/issues/240#issuecomment-140003499 +# pylint: disable=no-member +requests.packages.urllib3.disable_warnings() + + +CREATE_SIMPLE_DB = bool(os.environ.get("CREATE_SIMPLE_DB", "")) +CREATE_CLUSTER_DB = bool(os.environ.get("CREATE_CLUSTER_DB", "")) +CREATE_TLS_DB = bool(os.environ.get("CREATE_TLS_DB", "")) +CREATE_TLS_MUTUAL_AUTH_DB = bool(os.environ.get("CREATE_TLS_MUTUAL_AUTH_DB", "")) +CREATE_MODULES_DB = bool(os.environ.get("CREATE_MODULES_DB", "")) +CREATE_CRDB = bool(os.environ.get("CREATE_CRDB", "")) +CRDB_INSTANCES = os.environ.get("CRDB_INSTANCES", "") + + +USERNAME = 'demo@redislabs.com' +PASSWORD = '123456' + + +RLEC_API_BASE_URL = 'https://localhost:9443/v1' + + +COMMON_REQ_PARAMS = dict(auth=(USERNAME, PASSWORD), + verify=False,) + + +def get_module_data() -> dict: + """ + Returns a dict of module name to a dict containing: + - module_id + - module_name + - module_args + - semantic_version + """ + resp = requests.get(url=f'{RLEC_API_BASE_URL}/modules', **COMMON_REQ_PARAMS) + if not resp.ok: + raise Exception(f"Failed to get modules info: {resp.status_code}: {resp.text}") + data = resp.json() + module_dict = {} + for m in data: + module_dict[m['module_name']] = { + "module_id": m["uid"], + "module_name": m["module_name"], + "module_args": m["command_line_args"], + "semantic_version": m["semantic_version"], + } + return module_dict + + +def create_db(body: dict) -> dict: + """ + Create a bdb and return the response from the API. + """ + resp = requests.post(url=f'{RLEC_API_BASE_URL}/bdbs', + json=body, + **COMMON_REQ_PARAMS) + if not resp.ok: + raise Exception(f"Failed to create db: {resp.status_code}: {resp.text}") + data = resp.json() + return data + + +def create_simple_db() -> dict: + body = { + "name": "testdb", + "type": "redis", + "memory_size": 1024 * 1024 * 1024, # 1GB + "port": 12000 + } + return create_db(body) + + +def create_cluster_db() -> dict: + body = { + "name": "testdb", + "type": "redis", + "memory_size": 1024 * 1024 * 1024, # 1GB + "port": 12010, + "sharding": True, + "shards_count": 3, + # Default OSS Redis Cluster-like hashing policy. + # These regexes are taken from the RLEC REST API docs: + # https://storage.googleapis.com/rlecrestapi/rest-html/http_rest_api.html#bdb (see the 'shard_key_regex' attribute) + "shard_key_regex": [ + {"regex": ".*\\{(?.*)\\}.*" }, + {"regex": "(?.*)" } + ], + } + return create_db(body) + + +def create_tls_db() -> dict: + body = { + "name": "testtlsdb", + "type": "redis", + "memory_size": 100000, + "port": 12443, + "tls_mode": "enabled", + "enforce_client_authentication": "disabled" + } + return create_db(body) + + +def create_tls_mutual_auth_db() -> dict: + with open('./cert.pem') as f: + cert_str = f.read() + body = { + "name": "testtlsclientauthdb", + "type": "redis", + "memory_size": 100000, + "port": 12465, + "tls_mode": "enabled", + "enforce_client_authentication": "enabled", + "authentication_ssl_client_certs": [{ + "client_cert": cert_str, + }] + } + return create_db(body) + + +def create_modules_db(module_info: dict) -> dict: + body = { + "name": "modulesdb", + "type": "redis", + "memory_size": 100000, + "port": 12003, + "module_list": [ + module_info['ft'], + module_info['graph'], + module_info['timeseries'], + ] + } + return create_db(body) + + +def create_crdb() -> dict: + cluster_fqdns = CRDB_INSTANCES.split() + assert len(cluster_fqdns) >= 2, f"At least two clusters are needed for a CRDB, got {cluster_fqdns}" + crdb_cli_instances_args = (f"--instance fqdn={fqdn},username={USERNAME},password={PASSWORD}" + for fqdn in cluster_fqdns) + crdb_cli_instances_args = " ".join(crdb_cli_instances_args) + crdb_cli_command = f"/opt/redislabs/bin/crdb-cli crdb create --name mycrdb --memory-size 10mb --port 12005 --replication false --shards-count 1 {crdb_cli_instances_args}" + print("Running the following command:") + print(crdb_cli_command) + subprocess.run(crdb_cli_command.split()) + + +def main(): + + if CREATE_SIMPLE_DB: + print("Creating simple db...") + bdb = create_simple_db() + print("done") + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping simple db") + + if CREATE_CLUSTER_DB: + print("Creating cluster db...") + bdb = create_cluster_db() + print("done") + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping cluster db") + + if CREATE_TLS_DB: + print("Creating TLS db...") + bdb = create_tls_db() + print("done") + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping TLS db") + + if CREATE_TLS_MUTUAL_AUTH_DB: + print("Creating TLS mutual auth db...") + bdb = create_tls_mutual_auth_db() + print("done") + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping TLS mutual auth db") + + if CREATE_MODULES_DB: + print("Getting modules info...") + module_info = get_module_data() + print('done') + print("Creating modules db...") + bdb = create_modules_db(module_info) + print('done') + pprint.pprint(bdb) + print("\n\n") + else: + print("Skipping modules db") + + if CREATE_CRDB: + print("Creating CRDB...") + create_crdb() + print('done') + print("\n\n") + else: + print("Skipping CRDB") + + +if __name__ == '__main__': + main() diff --git a/redisinsight/api/test/test-runs/re-st/docker-compose.yml b/redisinsight/api/test/test-runs/re-st/docker-compose.yml new file mode 100644 index 0000000000..e1f508e749 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-st/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.4" + +services: + test: + env_file: + - ./re-st/.env + command: ['./wait-for-it.sh', 'redis:12000', '-s', '-t', '120', '--', 'yarn', 'test:api:ci:cov'] + redis: + build: ./re-st + cap_add: + - sys_resource + environment: + - CREATE_SIMPLE_DB=true +# ports: +# - 12000:12000 +# - 8443:8443 +# - 9443:9443 diff --git a/redisinsight/api/test/test-runs/re-st/run_re_and_create_db.sh b/redisinsight/api/test/test-runs/re-st/run_re_and_create_db.sh new file mode 100644 index 0000000000..1f8c28f883 --- /dev/null +++ b/redisinsight/api/test/test-runs/re-st/run_re_and_create_db.sh @@ -0,0 +1,39 @@ +#! /bin/bash + +set -e + +# enable job control +set -m + +/opt/start.sh & + +# This command queries the REST API and outputs the status code +CURL_CMD="curl --silent --fail --output /dev/null -i -w %{http_code} -u demo@redislabs.com:123456 -k https://localhost:9443/v1/nodes" + +# Wait to get 2 consecutive 200 responses from the REST API +while true +do + echo yay $CURL_CMD + CURL_CMD_OUTPUT=$($CURL_CMD || true) + if [ $CURL_CMD_OUTPUT == "200" ] + then + echo "Got 200 response, trying again in 5 seconds to verify..." + sleep 5 + if [ $($CURL_CMD || true) == "200" ] + then + echo "Got 200 response after 5 seconds again, proceeding..." + break + fi + else + echo "Did not get 200 response, got $CURL_CMD_OUTPUT, trying again in 10 seconds..." + sleep 10 + fi +done + +echo "Running Python script to create databases..." +python3 create_dbs.py + + +# now we bring the primary process back into the foreground +# and leave it there +fg diff --git a/redisinsight/api/test/test-runs/run-all.sh b/redisinsight/api/test/test-runs/run-all.sh new file mode 100755 index 0000000000..cbea419627 --- /dev/null +++ b/redisinsight/api/test/test-runs/run-all.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +BASEDIR=$(dirname $0) +$BASEDIR/start-test-run.sh -r redis-5 | +$BASEDIR/start-test-run.sh -r redis-6 +#echo "All Test Runs were executed" diff --git a/redisinsight/api/test/test-runs/start-test-run.sh b/redisinsight/api/test/test-runs/start-test-run.sh new file mode 100755 index 0000000000..af989bb0bb --- /dev/null +++ b/redisinsight/api/test/test-runs/start-test-run.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +BASEDIR=$(dirname $0) +BUILD="local" + +helpFunction() +{ + printf "Some of the required parameters are empty\n\n" + printf "Usage: %s -r RTE [-t local]\n" "$0" + printf " -r - (required) Redis Test Environment (RTE). Should match any service name from redis.docker-compose.yml\n" + printf " -t - Backend build type. + \t local - (default) run server using source code + \t docker - run server on built docker container + " + exit 1 # Exit script after printing help +} + +# required params +while getopts "r:t:" opt +do + case "$opt" in + r ) RTE="$OPTARG" ;; + t ) BUILD="$OPTARG" ;; + ? ) helpFunction ;; # Print helpFunction in case parameter is non-existent + esac +done +echo "BUILD: ${BUILD}" + +# Print helpFunction in case parameters are empty +if [ -z "$RTE" ] +then + helpFunction +fi + +# Unique ID for the test run +ID=$RTE-$(tr -dc A-Za-z0-9 /var/lib/dbus/machine-id + +WORKDIR /usr/src/app + +COPY package.json yarn.lock ./ +RUN yarn install +COPY . . + +COPY ./test/test-runs/test-docker-entry.sh ./test/test-runs/wait-for-it.sh ./ +RUN chmod +x test-docker-entry.sh +RUN chmod +x wait-for-it.sh + +ARG GNOME_KEYRING_PASS="somepass" +ENV GNOME_KEYRING_PASS=${GNOME_KEYRING_PASS} + +ENTRYPOINT ["./test-docker-entry.sh"] +CMD ["yarn", "test:api:ci:cov"] diff --git a/redisinsight/api/test/test-runs/wait-for-it.sh b/redisinsight/api/test/test-runs/wait-for-it.sh new file mode 100755 index 0000000000..d990e0d364 --- /dev/null +++ b/redisinsight/api/test/test-runs/wait-for-it.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# Check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) + +WAITFORIT_BUSYTIMEFLAG="" +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + # Check if busybox timeout uses -t flag + # (recent Alpine versions don't support -t anymore) + if timeout &>/dev/stdout | grep -q -e '-t '; then + WAITFORIT_BUSYTIMEFLAG="-t" + fi +else + WAITFORIT_ISBUSY=0 +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/redisinsight/api/tsconfig.build.json b/redisinsight/api/tsconfig.build.json new file mode 100644 index 0000000000..7c6ebd1051 --- /dev/null +++ b/redisinsight/api/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "node_modules", + "test", + "dist", + "ui", + "**/ui/**.*", + "**/*spec.ts", + "src/__mocks__" + ] +} diff --git a/redisinsight/api/tsconfig.build.prod.json b/redisinsight/api/tsconfig.build.prod.json new file mode 100644 index 0000000000..be340d514d --- /dev/null +++ b/redisinsight/api/tsconfig.build.prod.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "sourceMap": false + }, + "exclude": [ + "node_modules", + "test", + "dist", + "ui", + "**/ui/**.*", + "**/*spec.ts", + "src/__mocks__", + ] +} diff --git a/redisinsight/api/tsconfig.json b/redisinsight/api/tsconfig.json new file mode 100644 index 0000000000..d59e6a2a5f --- /dev/null +++ b/redisinsight/api/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "es2019", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "resolveJsonModule": true, + "incremental": true, + "paths": { + "src/*": [ + "src/*" + ], + "apiSrc/*": [ + "src/*" + ], + "tests/*": [ + "__tests__/*" + ] + } + } +} diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock new file mode 100644 index 0000000000..9ebe25d550 --- /dev/null +++ b/redisinsight/api/yarn.lock @@ -0,0 +1,8646 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@angular-devkit/core@11.2.4": + version "11.2.4" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-11.2.4.tgz#4404b86d8dbdb41a0e3f94cb08ff8604e0c49a2e" + integrity sha512-98mGDV4XtKWiQ/2D6yzvOHrnJovXchaAN9AjscAHd2an8Fkiq72d9m2wREpk+2J40NWTDB6J5iesTh3qbi8+CA== + dependencies: + ajv "6.12.6" + fast-json-stable-stringify "2.1.0" + magic-string "0.25.7" + rxjs "6.6.3" + source-map "0.7.3" + +"@angular-devkit/core@11.2.6": + version "11.2.6" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-11.2.6.tgz#f90dfcc7204cdc58dfcb9901ce265c5c9c0a5dfa" + integrity sha512-3dA0Z6sIIxCDjZS/DucgmIKti7EZ/LgHoHgCO72Q50H5ZXbUSNBz5wGl5hVq2+gzrnFgU/0u40MIs6eptk30ZA== + dependencies: + ajv "6.12.6" + fast-json-stable-stringify "2.1.0" + magic-string "0.25.7" + rxjs "6.6.3" + source-map "0.7.3" + +"@angular-devkit/schematics-cli@0.1102.6": + version "0.1102.6" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics-cli/-/schematics-cli-0.1102.6.tgz#51b9012913be94b6e8063a2f8839f7e4b652057b" + integrity sha512-86PmafA9mYDeM08cNWHcJCEY1Yqo5aq/YaBzCak93luByDQ4Ao4Jqts9l/xBCZBGUdVrczCNzcdwr/Y/6JPPzA== + dependencies: + "@angular-devkit/core" "11.2.6" + "@angular-devkit/schematics" "11.2.6" + "@schematics/schematics" "0.1102.6" + ansi-colors "4.1.1" + inquirer "7.3.3" + minimist "1.2.5" + symbol-observable "3.0.0" + +"@angular-devkit/schematics@11.2.4": + version "11.2.4" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-11.2.4.tgz#ba67ee835ceb210777f1feece86195f28c1b2e96" + integrity sha512-M9Ike1TYawOIHzenlZS1ufQbsS+Z11/doj5w/UrU0q2OEKc6U375t5qVGgKo3PLHHS8osb9aW9xYwBfVlKrryQ== + dependencies: + "@angular-devkit/core" "11.2.4" + ora "5.3.0" + rxjs "6.6.3" + +"@angular-devkit/schematics@11.2.6": + version "11.2.6" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-11.2.6.tgz#5908daef60af2e5d98fd75ac3fe77c02ab144fa3" + integrity sha512-bhi2+5xtVAjtr3bsXKT8pnoBamQrArd/Y20ueA4Od7cd38YT97nzTA1wyHBFG0vWd0HMyg42ZS0aycNBuOebaA== + dependencies: + "@angular-devkit/core" "11.2.6" + ora "5.3.0" + rxjs "6.6.3" + +"@babel/code-frame@7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== + dependencies: + "@babel/highlight" "^7.10.4" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.14.5", "@babel/code-frame@^7.8.3": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb" + integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw== + dependencies: + "@babel/highlight" "^7.14.5" + +"@babel/compat-data@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.14.5.tgz#8ef4c18e58e801c5c95d3c1c0f2874a2680fadea" + integrity sha512-kixrYn4JwfAVPa0f2yfzc2AWti6WRRyO3XjWW5PJAvtE11qhSayrrcrEnee05KAtNaPC+EwehE8Qt1UedEVB8w== + +"@babel/core@^7.1.0", "@babel/core@^7.7.5": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.14.6.tgz#e0814ec1a950032ff16c13a2721de39a8416fcab" + integrity sha512-gJnOEWSqTk96qG5BoIrl5bVtc23DCycmIePPYnamY9RboYdI4nFy5vAQMSl81O5K/W0sLDWfGysnOECC+KUUCA== + dependencies: + "@babel/code-frame" "^7.14.5" + "@babel/generator" "^7.14.5" + "@babel/helper-compilation-targets" "^7.14.5" + "@babel/helper-module-transforms" "^7.14.5" + "@babel/helpers" "^7.14.6" + "@babel/parser" "^7.14.6" + "@babel/template" "^7.14.5" + "@babel/traverse" "^7.14.5" + "@babel/types" "^7.14.5" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.1.2" + semver "^6.3.0" + source-map "^0.5.0" + +"@babel/generator@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.14.5.tgz#848d7b9f031caca9d0cd0af01b063f226f52d785" + integrity sha512-y3rlP+/G25OIX3mYKKIOlQRcqj7YgrvHxOLbVmyLJ9bPmi5ttvUmpydVjcFjZphOktWuA7ovbx91ECloWTfjIA== + dependencies: + "@babel/types" "^7.14.5" + jsesc "^2.5.1" + source-map "^0.5.0" + +"@babel/helper-compilation-targets@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.5.tgz#7a99c5d0967911e972fe2c3411f7d5b498498ecf" + integrity sha512-v+QtZqXEiOnpO6EYvlImB6zCD2Lel06RzOPzmkz/D/XgQiUu3C/Jb1LOqSt/AIA34TYi/Q+KlT8vTQrgdxkbLw== + dependencies: + "@babel/compat-data" "^7.14.5" + "@babel/helper-validator-option" "^7.14.5" + browserslist "^4.16.6" + semver "^6.3.0" + +"@babel/helper-function-name@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4" + integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ== + dependencies: + "@babel/helper-get-function-arity" "^7.14.5" + "@babel/template" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/helper-get-function-arity@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815" + integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-hoist-variables@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d" + integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-member-expression-to-functions@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.14.5.tgz#d5c70e4ad13b402c95156c7a53568f504e2fb7b8" + integrity sha512-UxUeEYPrqH1Q/k0yRku1JE7dyfyehNwT6SVkMHvYvPDv4+uu627VXBckVj891BO8ruKBkiDoGnZf4qPDD8abDQ== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-module-imports@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3" + integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-module-transforms@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.14.5.tgz#7de42f10d789b423eb902ebd24031ca77cb1e10e" + integrity sha512-iXpX4KW8LVODuAieD7MzhNjmM6dzYY5tfRqT+R9HDXWl0jPn/djKmA+G9s/2C2T9zggw5tK1QNqZ70USfedOwA== + dependencies: + "@babel/helper-module-imports" "^7.14.5" + "@babel/helper-replace-supers" "^7.14.5" + "@babel/helper-simple-access" "^7.14.5" + "@babel/helper-split-export-declaration" "^7.14.5" + "@babel/helper-validator-identifier" "^7.14.5" + "@babel/template" "^7.14.5" + "@babel/traverse" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/helper-optimise-call-expression@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz#f27395a8619e0665b3f0364cddb41c25d71b499c" + integrity sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.8.0": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9" + integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ== + +"@babel/helper-replace-supers@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.14.5.tgz#0ecc0b03c41cd567b4024ea016134c28414abb94" + integrity sha512-3i1Qe9/8x/hCHINujn+iuHy+mMRLoc77b2nI9TB0zjH1hvn9qGlXjWlggdwUcju36PkPCy/lpM7LLUdcTyH4Ow== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.14.5" + "@babel/helper-optimise-call-expression" "^7.14.5" + "@babel/traverse" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/helper-simple-access@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.14.5.tgz#66ea85cf53ba0b4e588ba77fc813f53abcaa41c4" + integrity sha512-nfBN9xvmCt6nrMZjfhkl7i0oTV3yxR4/FztsbOASyTvVcoYd0TRHh7eMLdlEcCqobydC0LAF3LtC92Iwxo0wyw== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-split-export-declaration@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a" + integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA== + dependencies: + "@babel/types" "^7.14.5" + +"@babel/helper-validator-identifier@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz#d0f0e277c512e0c938277faa85a3968c9a44c0e8" + integrity sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg== + +"@babel/helper-validator-option@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3" + integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow== + +"@babel/helpers@^7.14.6": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.14.6.tgz#5b58306b95f1b47e2a0199434fa8658fa6c21635" + integrity sha512-yesp1ENQBiLI+iYHSJdoZKUtRpfTlL1grDIX9NRlAVppljLw/4tTyYupIB7uIYmC3stW/imAv8EqaKaS/ibmeA== + dependencies: + "@babel/template" "^7.14.5" + "@babel/traverse" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/highlight@^7.10.4", "@babel/highlight@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9" + integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg== + dependencies: + "@babel/helper-validator-identifier" "^7.14.5" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.14.5", "@babel/parser@^7.14.6": + version "7.14.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.14.6.tgz#d85cc68ca3cac84eae384c06f032921f5227f4b2" + integrity sha512-oG0ej7efjEXxb4UgE+klVx+3j4MVo+A2vCzm7OUN4CLo6WhQ+vSOD2yJ8m7B+DghObxtLxt3EfgMWpq+AsWehQ== + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" + integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.8.3": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-import-meta@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-logical-assignment-operators@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-top-level-await@^7.8.3": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/template@^7.14.5", "@babel/template@^7.3.3": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4" + integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g== + dependencies: + "@babel/code-frame" "^7.14.5" + "@babel/parser" "^7.14.5" + "@babel/types" "^7.14.5" + +"@babel/traverse@^7.1.0", "@babel/traverse@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.14.5.tgz#c111b0f58afab4fea3d3385a406f692748c59870" + integrity sha512-G3BiS15vevepdmFqmUc9X+64y0viZYygubAMO8SvBmKARuF6CPSZtH4Ng9vi/lrWlZFGe3FWdXNy835akH8Glg== + dependencies: + "@babel/code-frame" "^7.14.5" + "@babel/generator" "^7.14.5" + "@babel/helper-function-name" "^7.14.5" + "@babel/helper-hoist-variables" "^7.14.5" + "@babel/helper-split-export-declaration" "^7.14.5" + "@babel/parser" "^7.14.5" + "@babel/types" "^7.14.5" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.14.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.14.5.tgz#3bb997ba829a2104cedb20689c4a5b8121d383ff" + integrity sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg== + dependencies: + "@babel/helper-validator-identifier" "^7.14.5" + to-fast-properties "^2.0.0" + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@cnakazawa/watch@^1.0.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a" + integrity sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ== + dependencies: + exec-sh "^0.3.2" + minimist "^1.2.0" + +"@dabh/diagnostics@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.2.tgz#290d08f7b381b8f94607dc8f471a12c675f9db31" + integrity sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q== + dependencies: + colorspace "1.1.x" + enabled "2.0.x" + kuler "^2.0.0" + +"@eslint/eslintrc@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.2.tgz#f63d0ef06f5c0c57d76c4ab5f63d3835c51b0179" + integrity sha512-8nmGq/4ycLpIwzvhI4tNDmQztZ8sp+hI7cyG8i1nQDhkAbRzHpXPidRAHlNvCZQpJTKw5ItIpMw9RSToGF00mg== + dependencies: + ajv "^6.12.4" + debug "^4.1.1" + espree "^7.3.0" + globals "^13.9.0" + ignore "^4.0.6" + import-fresh "^3.2.1" + js-yaml "^3.13.1" + minimatch "^3.0.4" + strip-json-comments "^3.1.1" + +"@hapi/hoek@^9.0.0": + version "9.2.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.0.tgz#f3933a44e365864f4dad5db94158106d511e8131" + integrity sha512-sqKVVVOe5ivCaXDWivIJYVSaEgdQK9ul7a4Kity5Iw7u9+wBAPbX1RMSnLLmp7O4Vzj0WOWwMAJsTL00xwaNug== + +"@hapi/topo@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7" + integrity sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" + integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + get-package-type "^0.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jest/console@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-24.9.0.tgz#79b1bc06fb74a8cfb01cbdedf945584b1b9707f0" + integrity sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ== + dependencies: + "@jest/source-map" "^24.9.0" + chalk "^2.0.1" + slash "^2.0.0" + +"@jest/console@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-26.6.2.tgz#4e04bc464014358b03ab4937805ee36a0aeb98f2" + integrity sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g== + dependencies: + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + jest-message-util "^26.6.2" + jest-util "^26.6.2" + slash "^3.0.0" + +"@jest/core@^26.6.3": + version "26.6.3" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-26.6.3.tgz#7639fcb3833d748a4656ada54bde193051e45fad" + integrity sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw== + dependencies: + "@jest/console" "^26.6.2" + "@jest/reporters" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.4" + jest-changed-files "^26.6.2" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" + jest-regex-util "^26.0.0" + jest-resolve "^26.6.2" + jest-resolve-dependencies "^26.6.3" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" + jest-watcher "^26.6.2" + micromatch "^4.0.2" + p-each-series "^2.1.0" + rimraf "^3.0.0" + slash "^3.0.0" + strip-ansi "^6.0.0" + +"@jest/environment@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-26.6.2.tgz#ba364cc72e221e79cc8f0a99555bf5d7577cf92c" + integrity sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA== + dependencies: + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + jest-mock "^26.6.2" + +"@jest/fake-timers@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-26.6.2.tgz#459c329bcf70cee4af4d7e3f3e67848123535aad" + integrity sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA== + dependencies: + "@jest/types" "^26.6.2" + "@sinonjs/fake-timers" "^6.0.1" + "@types/node" "*" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" + jest-util "^26.6.2" + +"@jest/globals@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-26.6.2.tgz#5b613b78a1aa2655ae908eba638cc96a20df720a" + integrity sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA== + dependencies: + "@jest/environment" "^26.6.2" + "@jest/types" "^26.6.2" + expect "^26.6.2" + +"@jest/reporters@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-26.6.2.tgz#1f518b99637a5f18307bd3ecf9275f6882a667f6" + integrity sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" + chalk "^4.0.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.2" + graceful-fs "^4.2.4" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^4.0.3" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.0.2" + jest-haste-map "^26.6.2" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" + slash "^3.0.0" + source-map "^0.6.0" + string-length "^4.0.1" + terminal-link "^2.0.0" + v8-to-istanbul "^7.0.0" + optionalDependencies: + node-notifier "^8.0.0" + +"@jest/source-map@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-24.9.0.tgz#0e263a94430be4b41da683ccc1e6bffe2a191714" + integrity sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg== + dependencies: + callsites "^3.0.0" + graceful-fs "^4.1.15" + source-map "^0.6.0" + +"@jest/source-map@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-26.6.2.tgz#29af5e1e2e324cafccc936f218309f54ab69d535" + integrity sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA== + dependencies: + callsites "^3.0.0" + graceful-fs "^4.2.4" + source-map "^0.6.0" + +"@jest/test-result@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-24.9.0.tgz#11796e8aa9dbf88ea025757b3152595ad06ba0ca" + integrity sha512-XEFrHbBonBJ8dGp2JmF8kP/nQI/ImPpygKHwQ/SY+es59Z3L5PI4Qb9TQQMAEeYsThG1xF0k6tmG0tIKATNiiA== + dependencies: + "@jest/console" "^24.9.0" + "@jest/types" "^24.9.0" + "@types/istanbul-lib-coverage" "^2.0.0" + +"@jest/test-result@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-26.6.2.tgz#55da58b62df134576cc95476efa5f7949e3f5f18" + integrity sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ== + dependencies: + "@jest/console" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/istanbul-lib-coverage" "^2.0.0" + collect-v8-coverage "^1.0.0" + +"@jest/test-sequencer@^26.6.3": + version "26.6.3" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz#98e8a45100863886d074205e8ffdc5a7eb582b17" + integrity sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw== + dependencies: + "@jest/test-result" "^26.6.2" + graceful-fs "^4.2.4" + jest-haste-map "^26.6.2" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" + +"@jest/transform@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-26.6.2.tgz#5ac57c5fa1ad17b2aae83e73e45813894dcf2e4b" + integrity sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA== + dependencies: + "@babel/core" "^7.1.0" + "@jest/types" "^26.6.2" + babel-plugin-istanbul "^6.0.0" + chalk "^4.0.0" + convert-source-map "^1.4.0" + fast-json-stable-stringify "^2.0.0" + graceful-fs "^4.2.4" + jest-haste-map "^26.6.2" + jest-regex-util "^26.0.0" + jest-util "^26.6.2" + micromatch "^4.0.2" + pirates "^4.0.1" + slash "^3.0.0" + source-map "^0.6.1" + write-file-atomic "^3.0.0" + +"@jest/types@^24.9.0": + version "24.9.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.9.0.tgz#63cb26cb7500d069e5a389441a7c6ab5e909fc59" + integrity sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^1.1.1" + "@types/yargs" "^13.0.0" + +"@jest/types@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" + integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^15.0.0" + chalk "^4.0.0" + +"@mochajs/json-file-reporter@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@mochajs/json-file-reporter/-/json-file-reporter-1.3.0.tgz#63a53bcda93d75f5c5c74af60e45da063931370b" + integrity sha512-evIxpeP8EOixo/T2xh5xYEIzwbEHk8YNJfRUm1KeTs8F3bMjgNn2580Ogze9yisXNlTxu88JiJJYzXjjg5NdLA== + +"@nestjs/cli@^7.5.4": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-7.6.0.tgz#661f99b578284f9124307a8077f004a091b25e77" + integrity sha512-lW1px2gSHkRoBpKSxzP6IJNQscRKs97OAaVyV46OAP6oUR996E0EPkIslIaa16kKLJ3SFOUeZo5xl5nYbqp43g== + dependencies: + "@angular-devkit/core" "11.2.6" + "@angular-devkit/schematics" "11.2.6" + "@angular-devkit/schematics-cli" "0.1102.6" + "@nestjs/schematics" "^7.3.0" + chalk "3.0.0" + chokidar "3.5.1" + cli-table3 "0.5.1" + commander "4.1.1" + fork-ts-checker-webpack-plugin "6.2.0" + inquirer "7.3.3" + node-emoji "1.10.0" + ora "5.4.0" + os-name "4.0.0" + rimraf "3.0.2" + shelljs "0.8.4" + tree-kill "1.2.2" + tsconfig-paths "3.9.0" + tsconfig-paths-webpack-plugin "3.5.1" + typescript "4.2.3" + webpack "5.28.0" + webpack-node-externals "2.5.2" + +"@nestjs/common@^7.6.15": + version "7.6.17" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-7.6.17.tgz#623c7f93117bea15fff07a6b63fcd644a8764655" + integrity sha512-RHvD32FxfV7yDWX9GPmn0ZSv7ka5kLeVamU5ZpoXSTUjkGqWFt3MTyIP+HUQD2778kDqT+CgEtVJ1fxDG5Oh9g== + dependencies: + axios "0.21.1" + iterare "1.2.1" + tslib "2.2.0" + uuid "8.3.2" + +"@nestjs/core@^7.0.0": + version "7.6.17" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-7.6.17.tgz#8fba8739e81f4206905109bec62b02a00530c258" + integrity sha512-dH7PGDj1dvBfOYgxJlxh54vdnFFSLst7+Spg3E7Jpo+n11Ht5Ee5mTjSzXieRVfFba/sI3NIHF/N1stn36bU9w== + dependencies: + "@nuxtjs/opencollective" "0.3.2" + fast-safe-stringify "2.0.7" + iterare "1.2.1" + object-hash "2.1.1" + path-to-regexp "3.2.0" + tslib "2.2.0" + uuid "8.3.2" + +"@nestjs/event-emitter@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@nestjs/event-emitter/-/event-emitter-1.0.0.tgz#aac176e70ca683cec4e9516a0a2985e173e29464" + integrity sha512-dRAou6G89KKYI2iyYfqSVGE6ZTC4WmHkQkFfgh88GLQg8dBqRk92ZY8CRtL2SK32SSelh9bwEDNQn9561uoypA== + dependencies: + eventemitter2 "6.4.4" + +"@nestjs/mapped-types@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-0.4.0.tgz#352b9a661d6d36863cf48b2057616cef1b2c802d" + integrity sha512-TVtd/aTb7EqPhVczdeuvzF9dY0fyE3ivvCstc2eO+AkNqrfzSG1kXYYiUUznKjd0qDa8g2TmPSmHUQ21AXsV1Q== + +"@nestjs/platform-express@^7.0.0": + version "7.6.17" + resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-7.6.17.tgz#80b6dc2ac3636af19b5d70573926b5b09da35810" + integrity sha512-lyMwx8X/zTXZzxrd6Xn8BEcS/wuFyEgRVk9f15Z29hSaWHd78mUlBXvSnKJpzsN7wTjU8YWbAy/Ig9kIBS6efg== + dependencies: + body-parser "1.19.0" + cors "2.8.5" + express "4.17.1" + multer "1.4.2" + tslib "2.2.0" + +"@nestjs/schematics@^7.0.0", "@nestjs/schematics@^7.3.0": + version "7.3.1" + resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-7.3.1.tgz#68b559d2e6a8a9ecf6c984f87eaa7d4e37a910be" + integrity sha512-eyBjJstAjecpdzRuBLiqnwomwXIAEV3+kPkpaphOieRUM6nBhjnXCCl3Qf8Dul2QUQK4NOVPd8FFxWtGP5XNlg== + dependencies: + "@angular-devkit/core" "11.2.4" + "@angular-devkit/schematics" "11.2.4" + fs-extra "9.1.0" + jsonc-parser "3.0.0" + pluralize "8.0.0" + +"@nestjs/serve-static@^2.1.3": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@nestjs/serve-static/-/serve-static-2.1.4.tgz#d25f7691b0cb19d3f12d161129dd1469dfdc880d" + integrity sha512-w2PpLKzQOB8rJ+vMOy28xm8jwE8VjJfA9U+KOm0H0OY62g2oOWJ+OQPSDogP7XxAzZwq+Bt8wNU2oS8+z6v6Zg== + dependencies: + path-to-regexp "0.1.7" + +"@nestjs/swagger@^4.6.1": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@nestjs/swagger/-/swagger-4.8.0.tgz#7ebfeb0d59e0c27ff40beb429d7311b752c0dca4" + integrity sha512-YU+ahCOoOTZwSHrODHBiQDCqi7GWEjmSFg3Tot/lwVuQ321/3fIOz/lf+ehVQ5DFr7nVMhB7BRWFJLtE/+NhqQ== + dependencies: + "@nestjs/mapped-types" "0.4.0" + lodash "4.17.21" + path-to-regexp "3.2.0" + +"@nestjs/testing@^7.0.0": + version "7.6.17" + resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-7.6.17.tgz#dab17527dbbc12c674b21de9527d280ee065932b" + integrity sha512-wWImNvfRapCCtLXMsxCs1Ax2Uj/qSytCnolSEXL7LnH80exwHRmBeLtTfGxArsv9Y1NHr24NarfN6H0QxysZ3g== + dependencies: + optional "0.1.4" + tslib "2.2.0" + +"@nestjs/typeorm@^7.1.5": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@nestjs/typeorm/-/typeorm-7.1.5.tgz#50e3bf85ff8cf78d47d8dd19210c5f02b488f5e3" + integrity sha512-utE1FkYM/gyCXUqw3zKYYS0YZ3DfkAnzsCx4T48cNnSDTCeWS+u3yt0FMDFjwSiQSaLrzpiSff/FaxJQvRlYow== + dependencies: + uuid "8.3.1" + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.7" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.7.tgz#94c23db18ee4653e129abd26fb06f870ac9e1ee2" + integrity sha512-BTIhocbPBSrRmHxOAJFtR18oLhxTtAFDAvL8hY1S3iU8k+E60W/YFs4jrixGzQjMpF4qPXxIQHcjVD9dz1C2QA== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@nuxtjs/opencollective@0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz#620ce1044f7ac77185e825e1936115bb38e2681c" + integrity sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA== + dependencies: + chalk "^4.1.0" + consola "^2.15.0" + node-fetch "^2.6.1" + +"@schematics/schematics@0.1102.6": + version "0.1102.6" + resolved "https://registry.yarnpkg.com/@schematics/schematics/-/schematics-0.1102.6.tgz#2ce02f7c11558471628eafeb34faaa7f5ab4b22c" + integrity sha512-x77kbJL/HqR4gx0tbt35VCOGLyMvB7jD/x7eB1njhQRF8E/xynEOk3i+7A5VmK67QP5NJxU8BQKlPkJ55tBDmg== + dependencies: + "@angular-devkit/core" "11.2.6" + "@angular-devkit/schematics" "11.2.6" + +"@segment/loosely-validate-event@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz#87dfc979e5b4e7b82c5f1d8b722dfd5d77644681" + integrity sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw== + dependencies: + component-type "^1.2.1" + join-component "^1.1.0" + +"@sideway/address@^4.1.0": + version "4.1.2" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.2.tgz#811b84333a335739d3969cfc434736268170cad1" + integrity sha512-idTz8ibqWFrPU8kMirL0CoPH/A29XOzzAzpyN3zQ4kAWnzmNfFmRaoMNN6VI8ske5M73HZyhIaW4OuSFIdM4oA== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" + integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + +"@sinonjs/commons@^1.7.0": + version "1.8.3" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" + integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40" + integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@sqltools/formatter@^1.2.2": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20" + integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg== + +"@tootallnate/once@1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" + integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== + +"@types/axios@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@types/axios/-/axios-0.14.0.tgz#ec2300fbe7d7dddd7eb9d3abf87999964cafce46" + integrity sha1-7CMA++fX3d1+udOr+HmZlkyvzkY= + dependencies: + axios "*" + +"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": + version "7.1.14" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402" + integrity sha512-zGZJzzBUVDo/eV6KgbE0f0ZI7dInEYvo12Rb70uNQDshC3SkRMb67ja0GgRHZgAX3Za6rhaWlvbDO8rrGyAb1g== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.6.2" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.2.tgz#f3d71178e187858f7c45e30380f8f1b7415a12d8" + integrity sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.0.tgz#0c888dd70b3ee9eebb6e4f200e809da0076262be" + integrity sha512-NTPErx4/FiPCGScH7foPyr+/1Dkzkni+rHiYHHoTjvwou7AQzJkNeD60A9CXRy+ZEN2B1bggmkTMCDb+Mv5k+A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.4", "@types/babel__traverse@^7.0.6": + version "7.11.1" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.11.1.tgz#654f6c4f67568e24c23b367e947098c6206fa639" + integrity sha512-Vs0hm0vPahPMYi9tDjtP66llufgO3ST16WXaSTtDGEl9cewAl3AibmxWw6TINOqHPT9z0uABKAYjT9jNSg4npw== + dependencies: + "@babel/types" "^7.3.0" + +"@types/body-parser@*": + version "1.19.0" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" + integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.34" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.34.tgz#170a40223a6d666006d93ca128af2beb1d9b1901" + integrity sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ== + dependencies: + "@types/node" "*" + +"@types/cookiejar@*": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.2.tgz#66ad9331f63fe8a3d3d9d8c6e3906dd10f6446e8" + integrity sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog== + +"@types/eslint-scope@^3.7.0": + version "3.7.0" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.0.tgz#4792816e31119ebd506902a482caec4951fabd86" + integrity sha512-O/ql2+rrCUe2W2rs7wMR+GqPRcgB6UiqN5RhrR5xruFlY7l9YLMn0ZkDzjoHLeiFkR8MCQZVudUuuvQ2BLC9Qw== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + +"@types/eslint@*": + version "7.2.13" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.2.13.tgz#e0ca7219ba5ded402062ad6f926d491ebb29dd53" + integrity sha512-LKmQCWAlnVHvvXq4oasNUMTJJb2GwSyTY8+1C7OH5ILR8mPLaljv1jxL1bXW3xB3jFbQxTKxJAvI8PyjB09aBg== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*": + version "0.0.48" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.48.tgz#18dc8091b285df90db2f25aa7d906cfc394b7f74" + integrity sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew== + +"@types/estree@^0.0.46": + version "0.0.46" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.46.tgz#0fb6bfbbeabd7a30880504993369c4bf1deab1fe" + integrity sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg== + +"@types/express-serve-static-core@^4.17.18": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.21.tgz#a427278e106bca77b83ad85221eae709a3414d42" + integrity sha512-gwCiEZqW6f7EoR8TTEfalyEhb1zA5jQJnRngr97+3pzMaO1RKoI1w2bw07TK72renMUVWcWS5mLI6rk1NqN0nA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + +"@types/express@^4.17.3": + version "4.17.12" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.12.tgz#4bc1bf3cd0cfe6d3f6f2853648b40db7d54de350" + integrity sha512-pTYas6FrP15B1Oa0bkN5tQMNqOcVXa9j4FTFtO8DWI9kppKib+6NJtfTOOLcwxuuYvcX2+dVG6et1SxW/Kc17Q== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.18" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/graceful-fs@^4.1.2": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" + integrity sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw== + dependencies: + "@types/node" "*" + +"@types/ioredis@^4.22.3": + version "4.26.4" + resolved "https://registry.yarnpkg.com/@types/ioredis/-/ioredis-4.26.4.tgz#a2b1ed51ddd2c707d7eaac5017cc34a0fe51558a" + integrity sha512-QFbjNq7EnOGw6d1gZZt2h26OFXjx7z+eqEnbCHSrDI1OOLEgOHMKdtIajJbuCr9uO+X9kQQRe7Lz6uxqxl5XKg== + dependencies: + "@types/node" "*" + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" + integrity sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw== + +"@types/istanbul-lib-report@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" + integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz#e875cc689e47bce549ec81f3df5e6f6f11cfaeb2" + integrity sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw== + dependencies: + "@types/istanbul-lib-coverage" "*" + "@types/istanbul-lib-report" "*" + +"@types/istanbul-reports@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff" + integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/jest@^26.0.15": + version "26.0.23" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.23.tgz#a1b7eab3c503b80451d019efb588ec63522ee4e7" + integrity sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA== + dependencies: + jest-diff "^26.0.0" + pretty-format "^26.0.0" + +"@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.7": + version "7.0.7" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" + integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== + +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= + +"@types/lodash@^4.14.167": + version "4.14.170" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.170.tgz#0d67711d4bf7f4ca5147e9091b847479b87925d6" + integrity sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q== + +"@types/mime@^1": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" + integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== + +"@types/node@*": + version "15.12.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.2.tgz#1f2b42c4be7156ff4a6f914b2fb03d05fa84e38d" + integrity sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww== + +"@types/node@14.14.10": + version "14.14.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.10.tgz#5958a82e41863cfc71f2307b3748e3491ba03785" + integrity sha512-J32dgx2hw8vXrSbu4ZlVhn1Nm3GbeCFNw2FWL8S5QKucHGY0cyNwjdQdO+KMBZ4wpmC7KhLCiNsdk1RFRIYUQQ== + +"@types/normalize-package-data@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" + integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== + +"@types/parse-json@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" + integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== + +"@types/prettier@^2.0.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.3.0.tgz#2e8332cc7363f887d32ec5496b207d26ba8052bb" + integrity sha512-hkc1DATxFLQo4VxPDpMH1gCkPpBbpOoJ/4nhuXw4n63/0R6bCpQECj4+K226UJ4JO/eJQz+1mC2I7JsWanAdQw== + +"@types/qs@*": + version "6.9.6" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1" + integrity sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA== + +"@types/range-parser@*": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" + integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA== + +"@types/serve-static@*": + version "1.13.9" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e" + integrity sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@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/stack-utils@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" + integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw== + +"@types/superagent@*": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-4.1.11.tgz#4822bc64a82a0f579261a77097dbca276556c20e" + integrity sha512-cZkWBXZI+jESnUTp8RDGBmk1Zn2MkScP4V5bjD7DyqB7L0WNWpblh4KX5K/6aTqxFZMhfo1bhi2cwoAEDVBBJw== + dependencies: + "@types/cookiejar" "*" + "@types/node" "*" + +"@types/supertest@^2.0.8": + version "2.0.11" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.11.tgz#2e70f69f220bc77b4f660d72c2e1a4231f44a77d" + integrity sha512-uci4Esokrw9qGb9bvhhSVEjd6rkny/dk5PK/Qz4yxKiyppEI+dOPlNrZBahE3i+PoKFYyDxChVXZ/ysS/nrm1Q== + dependencies: + "@types/superagent" "*" + +"@types/validator@13.0.0": + version "13.0.0" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.0.0.tgz#365f1bf936aeaddd0856fc41aa1d6f82d88ee5b3" + integrity sha512-WAy5txG7aFX8Vw3sloEKp5p/t/Xt8jD3GRD9DacnFv6Vo8ubudAsRTXgxpQwU0mpzY/H8U4db3roDuCMjShBmw== + +"@types/yargs-parser@*": + version "20.2.0" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9" + integrity sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA== + +"@types/yargs@^13.0.0": + version "13.0.11" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-13.0.11.tgz#def2f0c93e4bdf2c61d7e34899b17e34be28d3b1" + integrity sha512-NRqD6T4gktUrDi1o1wLH3EKC1o2caCr7/wR87ODcbVITQF106OM3sFN92ysZ++wqelOd1CTzatnOBRDYYG6wGQ== + dependencies: + "@types/yargs-parser" "*" + +"@types/yargs@^15.0.0": + version "15.0.13" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.13.tgz#34f7fec8b389d7f3c1fd08026a5763e072d3c6dc" + integrity sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ== + dependencies: + "@types/yargs-parser" "*" + +"@types/zen-observable@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71" + integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg== + +"@typescript-eslint/eslint-plugin@^4.8.1": + version "4.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.27.0.tgz#0b7fc974e8bc9b2b5eb98ed51427b0be529b4ad0" + integrity sha512-DsLqxeUfLVNp3AO7PC3JyaddmEHTtI9qTSAs+RB6ja27QvIM0TA8Cizn1qcS6vOu+WDLFJzkwkgweiyFhssDdQ== + dependencies: + "@typescript-eslint/experimental-utils" "4.27.0" + "@typescript-eslint/scope-manager" "4.27.0" + debug "^4.3.1" + functional-red-black-tree "^1.0.1" + lodash "^4.17.21" + regexpp "^3.1.0" + semver "^7.3.5" + tsutils "^3.21.0" + +"@typescript-eslint/experimental-utils@4.27.0": + version "4.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.27.0.tgz#78192a616472d199f084eab8f10f962c0757cd1c" + integrity sha512-n5NlbnmzT2MXlyT+Y0Jf0gsmAQzCnQSWXKy4RGSXVStjDvS5we9IWbh7qRVKdGcxT0WYlgcCYUK/HRg7xFhvjQ== + dependencies: + "@types/json-schema" "^7.0.7" + "@typescript-eslint/scope-manager" "4.27.0" + "@typescript-eslint/types" "4.27.0" + "@typescript-eslint/typescript-estree" "4.27.0" + eslint-scope "^5.1.1" + eslint-utils "^3.0.0" + +"@typescript-eslint/parser@^4.4.1": + version "4.29.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.29.1.tgz#17dfbb45c9032ffa0fe15881d20fbc2a4bdeb02d" + integrity sha512-3fL5iN20hzX3Q4OkG7QEPFjZV2qsVGiDhEwwh+EkmE/w7oteiOvUNzmpu5eSwGJX/anCryONltJ3WDmAzAoCMg== + dependencies: + "@typescript-eslint/scope-manager" "4.29.1" + "@typescript-eslint/types" "4.29.1" + "@typescript-eslint/typescript-estree" "4.29.1" + debug "^4.3.1" + +"@typescript-eslint/parser@^4.8.1": + version "4.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.27.0.tgz#85447e573364bce4c46c7f64abaa4985aadf5a94" + integrity sha512-XpbxL+M+gClmJcJ5kHnUpBGmlGdgNvy6cehgR6ufyxkEJMGP25tZKCaKyC0W/JVpuhU3VU1RBn7SYUPKSMqQvQ== + dependencies: + "@typescript-eslint/scope-manager" "4.27.0" + "@typescript-eslint/types" "4.27.0" + "@typescript-eslint/typescript-estree" "4.27.0" + debug "^4.3.1" + +"@typescript-eslint/scope-manager@4.27.0": + version "4.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.27.0.tgz#b0b1de2b35aaf7f532e89c8e81d0fa298cae327d" + integrity sha512-DY73jK6SEH6UDdzc6maF19AHQJBFVRf6fgAXHPXCGEmpqD4vYgPEzqpFz1lf/daSbOcMpPPj9tyXXDPW2XReAw== + dependencies: + "@typescript-eslint/types" "4.27.0" + "@typescript-eslint/visitor-keys" "4.27.0" + +"@typescript-eslint/scope-manager@4.29.1": + version "4.29.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.29.1.tgz#f25da25bc6512812efa2ce5ebd36619d68e61358" + integrity sha512-Hzv/uZOa9zrD/W5mftZa54Jd5Fed3tL6b4HeaOpwVSabJK8CJ+2MkDasnX/XK4rqP5ZTWngK1ZDeCi6EnxPQ7A== + dependencies: + "@typescript-eslint/types" "4.29.1" + "@typescript-eslint/visitor-keys" "4.29.1" + +"@typescript-eslint/types@4.27.0": + version "4.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.27.0.tgz#712b408519ed699baff69086bc59cd2fc13df8d8" + integrity sha512-I4ps3SCPFCKclRcvnsVA/7sWzh7naaM/b4pBO2hVxnM3wrU51Lveybdw5WoIktU/V4KfXrTt94V9b065b/0+wA== + +"@typescript-eslint/types@4.29.1": + version "4.29.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.29.1.tgz#94cce6cf7cc83451df03339cda99d326be2feaf5" + integrity sha512-Jj2yu78IRfw4nlaLtKjVaGaxh/6FhofmQ/j8v3NXmAiKafbIqtAPnKYrf0sbGjKdj0hS316J8WhnGnErbJ4RCA== + +"@typescript-eslint/typescript-estree@4.27.0": + version "4.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.27.0.tgz#189a7b9f1d0717d5cccdcc17247692dedf7a09da" + integrity sha512-KH03GUsUj41sRLLEy2JHstnezgpS5VNhrJouRdmh6yNdQ+yl8w5LrSwBkExM+jWwCJa7Ct2c8yl8NdtNRyQO6g== + dependencies: + "@typescript-eslint/types" "4.27.0" + "@typescript-eslint/visitor-keys" "4.27.0" + debug "^4.3.1" + globby "^11.0.3" + is-glob "^4.0.1" + semver "^7.3.5" + tsutils "^3.21.0" + +"@typescript-eslint/typescript-estree@4.29.1": + version "4.29.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.29.1.tgz#7b32a25ff8e51f2671ccc6b26cdbee3b1e6c5e7f" + integrity sha512-lIkkrR9E4lwZkzPiRDNq0xdC3f2iVCUjw/7WPJ4S2Sl6C3nRWkeE1YXCQ0+KsiaQRbpY16jNaokdWnm9aUIsfw== + dependencies: + "@typescript-eslint/types" "4.29.1" + "@typescript-eslint/visitor-keys" "4.29.1" + debug "^4.3.1" + globby "^11.0.3" + is-glob "^4.0.1" + semver "^7.3.5" + tsutils "^3.21.0" + +"@typescript-eslint/visitor-keys@4.27.0": + version "4.27.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.27.0.tgz#f56138b993ec822793e7ebcfac6ffdce0a60cb81" + integrity sha512-es0GRYNZp0ieckZ938cEANfEhsfHrzuLrePukLKtY3/KPXcq1Xd555Mno9/GOgXhKzn0QfkDLVgqWO3dGY80bg== + dependencies: + "@typescript-eslint/types" "4.27.0" + eslint-visitor-keys "^2.0.0" + +"@typescript-eslint/visitor-keys@4.29.1": + version "4.29.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.29.1.tgz#0615be8b55721f5e854f3ee99f1a714f2d093e5d" + integrity sha512-zLqtjMoXvgdZY/PG6gqA73V8BjqPs4af1v2kiiETBObp+uC6gRYnJLmJHxC0QyUrrHDLJPIWNYxoBV3wbcRlag== + dependencies: + "@typescript-eslint/types" "4.29.1" + eslint-visitor-keys "^2.0.0" + +"@ungap/promise-all-settled@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" + integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== + +"@webassemblyjs/ast@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.0.tgz#a5aa679efdc9e51707a4207139da57920555961f" + integrity sha512-kX2W49LWsbthrmIRMbQZuQDhGtjyqXfEmmHyEi4XWnSZtPmxY0+3anPIzsnRb45VH/J55zlOfWvZuY47aJZTJg== + dependencies: + "@webassemblyjs/helper-numbers" "1.11.0" + "@webassemblyjs/helper-wasm-bytecode" "1.11.0" + +"@webassemblyjs/floating-point-hex-parser@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.0.tgz#34d62052f453cd43101d72eab4966a022587947c" + integrity sha512-Q/aVYs/VnPDVYvsCBL/gSgwmfjeCb4LW8+TMrO3cSzJImgv8lxxEPM2JA5jMrivE7LSz3V+PFqtMbls3m1exDA== + +"@webassemblyjs/helper-api-error@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.0.tgz#aaea8fb3b923f4aaa9b512ff541b013ffb68d2d4" + integrity sha512-baT/va95eXiXb2QflSx95QGT5ClzWpGaa8L7JnJbgzoYeaA27FCvuBXU758l+KXWRndEmUXjP0Q5fibhavIn8w== + +"@webassemblyjs/helper-buffer@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.0.tgz#d026c25d175e388a7dbda9694e91e743cbe9b642" + integrity sha512-u9HPBEl4DS+vA8qLQdEQ6N/eJQ7gT7aNvMIo8AAWvAl/xMrcOSiI2M0MAnMCy3jIFke7bEee/JwdX1nUpCtdyA== + +"@webassemblyjs/helper-numbers@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.0.tgz#7ab04172d54e312cc6ea4286d7d9fa27c88cd4f9" + integrity sha512-DhRQKelIj01s5IgdsOJMKLppI+4zpmcMQ3XboFPLwCpSNH6Hqo1ritgHgD0nqHeSYqofA6aBN/NmXuGjM1jEfQ== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.11.0" + "@webassemblyjs/helper-api-error" "1.11.0" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/helper-wasm-bytecode@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.0.tgz#85fdcda4129902fe86f81abf7e7236953ec5a4e1" + integrity sha512-MbmhvxXExm542tWREgSFnOVo07fDpsBJg3sIl6fSp9xuu75eGz5lz31q7wTLffwL3Za7XNRCMZy210+tnsUSEA== + +"@webassemblyjs/helper-wasm-section@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.0.tgz#9ce2cc89300262509c801b4af113d1ca25c1a75b" + integrity sha512-3Eb88hcbfY/FCukrg6i3EH8H2UsD7x8Vy47iVJrP967A9JGqgBVL9aH71SETPx1JrGsOUVLo0c7vMCN22ytJew== + dependencies: + "@webassemblyjs/ast" "1.11.0" + "@webassemblyjs/helper-buffer" "1.11.0" + "@webassemblyjs/helper-wasm-bytecode" "1.11.0" + "@webassemblyjs/wasm-gen" "1.11.0" + +"@webassemblyjs/ieee754@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.0.tgz#46975d583f9828f5d094ac210e219441c4e6f5cf" + integrity sha512-KXzOqpcYQwAfeQ6WbF6HXo+0udBNmw0iXDmEK5sFlmQdmND+tr773Ti8/5T/M6Tl/413ArSJErATd8In3B+WBA== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.0.tgz#f7353de1df38aa201cba9fb88b43f41f75ff403b" + integrity sha512-aqbsHa1mSQAbeeNcl38un6qVY++hh8OpCOzxhixSYgbRfNWcxJNJQwe2rezK9XEcssJbbWIkblaJRwGMS9zp+g== + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.0.tgz#86e48f959cf49e0e5091f069a709b862f5a2cadf" + integrity sha512-A/lclGxH6SpSLSyFowMzO/+aDEPU4hvEiooCMXQPcQFPPJaYcPQNKGOCLUySJsYJ4trbpr+Fs08n4jelkVTGVw== + +"@webassemblyjs/wasm-edit@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.0.tgz#ee4a5c9f677046a210542ae63897094c2027cb78" + integrity sha512-JHQ0damXy0G6J9ucyKVXO2j08JVJ2ntkdJlq1UTiUrIgfGMmA7Ik5VdC/L8hBK46kVJgujkBIoMtT8yVr+yVOQ== + dependencies: + "@webassemblyjs/ast" "1.11.0" + "@webassemblyjs/helper-buffer" "1.11.0" + "@webassemblyjs/helper-wasm-bytecode" "1.11.0" + "@webassemblyjs/helper-wasm-section" "1.11.0" + "@webassemblyjs/wasm-gen" "1.11.0" + "@webassemblyjs/wasm-opt" "1.11.0" + "@webassemblyjs/wasm-parser" "1.11.0" + "@webassemblyjs/wast-printer" "1.11.0" + +"@webassemblyjs/wasm-gen@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.0.tgz#3cdb35e70082d42a35166988dda64f24ceb97abe" + integrity sha512-BEUv1aj0WptCZ9kIS30th5ILASUnAPEvE3tVMTrItnZRT9tXCLW2LEXT8ezLw59rqPP9klh9LPmpU+WmRQmCPQ== + dependencies: + "@webassemblyjs/ast" "1.11.0" + "@webassemblyjs/helper-wasm-bytecode" "1.11.0" + "@webassemblyjs/ieee754" "1.11.0" + "@webassemblyjs/leb128" "1.11.0" + "@webassemblyjs/utf8" "1.11.0" + +"@webassemblyjs/wasm-opt@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.0.tgz#1638ae188137f4bb031f568a413cd24d32f92978" + integrity sha512-tHUSP5F4ywyh3hZ0+fDQuWxKx3mJiPeFufg+9gwTpYp324mPCQgnuVKwzLTZVqj0duRDovnPaZqDwoyhIO8kYg== + dependencies: + "@webassemblyjs/ast" "1.11.0" + "@webassemblyjs/helper-buffer" "1.11.0" + "@webassemblyjs/wasm-gen" "1.11.0" + "@webassemblyjs/wasm-parser" "1.11.0" + +"@webassemblyjs/wasm-parser@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.0.tgz#3e680b8830d5b13d1ec86cc42f38f3d4a7700754" + integrity sha512-6L285Sgu9gphrcpDXINvm0M9BskznnzJTE7gYkjDbxET28shDqp27wpruyx3C2S/dvEwiigBwLA1cz7lNUi0kw== + dependencies: + "@webassemblyjs/ast" "1.11.0" + "@webassemblyjs/helper-api-error" "1.11.0" + "@webassemblyjs/helper-wasm-bytecode" "1.11.0" + "@webassemblyjs/ieee754" "1.11.0" + "@webassemblyjs/leb128" "1.11.0" + "@webassemblyjs/utf8" "1.11.0" + +"@webassemblyjs/wast-printer@1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.0.tgz#680d1f6a5365d6d401974a8e949e05474e1fab7e" + integrity sha512-Fg5OX46pRdTgB7rKIUojkh9vXaVN6sGYCnEiJN1GYkb0RPwShZXp6KTDqmoMdQPKhcroOXh3fEzmkWmCYaKYhQ== + dependencies: + "@webassemblyjs/ast" "1.11.0" + "@xtuc/long" "4.2.2" + +"@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.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" + integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== + +abab@^2.0.3, abab@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" + integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== + +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.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" + integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== + dependencies: + mime-types "~2.1.24" + negotiator "0.6.2" + +acorn-globals@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45" + integrity sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg== + dependencies: + acorn "^7.1.1" + acorn-walk "^7.1.1" + +acorn-jsx@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b" + integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== + +acorn-walk@^7.1.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" + integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== + +acorn@^7.1.1, acorn@^7.4.0: + version "7.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + +acorn@^8.0.4, acorn@^8.2.4: + version "8.4.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.4.0.tgz#af53266e698d7cffa416714b503066a82221be60" + integrity sha512-ULr0LDaEqQrMFGyQ3bhJkLsbtrQ8QibAseGZeaSUiT/6zb9IvIkomWHJIvgvwad+hinRAgsI51JcWk2yvwyL+w== + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + +ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== + +ajv@6.12.6, ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.0.1: + version "8.6.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.0.tgz#60cc45d9c46a477d80d92c48076d972c342e5720" + integrity sha512-cnUG4NSBiM4YFBxgZIj/In3/6KX+rQ2l2YPRVcvAMQGWEPKuXoPIhxzwqh31jA3IPbI4qEOp/5ILI4ynioXsGQ== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + +analytics-node@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/analytics-node/-/analytics-node-4.0.1.tgz#f3d20738d4da1e4aa7236d654d9f580e254a7437" + integrity sha512-+zXOOTB+eTRW6R9+pfvPfk1dHraFJzhNnAyZiYJIDGOjHQgfk9qfqgoJX9MfR4qY0J/E1YJ3FBncrLGadTDW1A== + dependencies: + "@segment/loosely-validate-event" "^2.0.0" + axios "^0.21.1" + axios-retry "^3.0.2" + lodash.isstring "^4.0.1" + md5 "^2.2.1" + ms "^2.0.0" + remove-trailing-slash "^0.1.0" + uuid "^3.2.1" + +ansi-colors@4.1.1, ansi-colors@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-regex@^2.0.0, ansi-regex@^2.1.1: + 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-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + +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" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +any-promise@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" + integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= + +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" + +anymatch@^3.0.3, anymatch@~3.1.1, anymatch@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +app-root-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-3.0.0.tgz#210b6f43873227e18a4b810a032283311555d5ad" + integrity sha512-qMcx+Gy2UZynHjOHOIXPNvpf+9cjvk3cWrBBK7zg4gH9+clobJRb9NGzcT7mQTcV/6Gm/1WelUtqxVXnNlrwcw== + +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY= + +append-transform@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-2.0.0.tgz#99d9d29c7b38391e6f428d28ce136551f0b77e12" + integrity sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg== + dependencies: + default-require-extensions "^3.0.0" + +aproba@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + +archy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" + integrity sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= + +are-we-there-yet@~1.1.2: + version "1.1.7" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz#b15474a932adab4ff8a50d9adfa7e4e926f21146" + integrity sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +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" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +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.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-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-includes@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a" + integrity sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.2" + get-intrinsic "^1.1.1" + is-string "^1.0.5" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +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.flat@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123" + integrity sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + es-abstract "^1.18.0-next.1" + +arrify@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= + +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= + +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + +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= + +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + +async@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + +atob@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +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.11.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" + integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== + +axios-retry@^3.0.2: + version "3.1.9" + resolved "https://registry.yarnpkg.com/axios-retry/-/axios-retry-3.1.9.tgz#6c30fc9aeb4519aebaec758b90ef56fa03fe72e8" + integrity sha512-NFCoNIHq8lYkJa6ku4m+V1837TP6lCa7n79Iuf8/AqATAHYB0ISaAS1eyIenDOfHOLtym34W65Sjke2xjg2fsA== + dependencies: + is-retry-allowed "^1.1.0" + +axios@*, axios@0.21.1, axios@^0.21.0, axios@^0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + +babel-jest@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056" + integrity sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA== + dependencies: + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/babel__core" "^7.1.7" + babel-plugin-istanbul "^6.0.0" + babel-preset-jest "^26.6.2" + chalk "^4.0.0" + graceful-fs "^4.2.4" + slash "^3.0.0" + +babel-plugin-istanbul@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.0.0.tgz#e159ccdc9af95e0b570c75b4573b7c34d671d765" + integrity sha512-AF55rZXpe7trmEylbaE1Gv54wn6rwU03aptvRoVIGP8YykoSxqdVLV1TfwflBCE/QtHmqtP8SWlTENqbK8GCSQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-instrument "^4.0.0" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz#8185bd030348d254c6d7dd974355e6a28b21e62d" + integrity sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw== + dependencies: + "@babel/template" "^7.3.3" + "@babel/types" "^7.3.3" + "@types/babel__core" "^7.0.0" + "@types/babel__traverse" "^7.0.6" + +babel-preset-current-node-syntax@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" + integrity sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.8.3" + "@babel/plugin-syntax-import-meta" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.8.3" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-top-level-await" "^7.8.3" + +babel-preset-jest@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz#747872b1171df032252426586881d62d31798fee" + integrity sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ== + dependencies: + babel-plugin-jest-hoist "^26.6.2" + babel-preset-current-node-syntax "^1.0.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +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" + +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" + +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@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + +bl@^4.0.3, bl@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +block-stream@*: + version "0.0.9" + resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" + integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo= + dependencies: + inherits "~2.0.0" + +body-parser@1.19.0, body-parser@^1.19.0: + version "1.19.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== + dependencies: + bytes "3.1.0" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.2" + http-errors "1.7.2" + iconv-lite "0.4.24" + on-finished "~2.3.0" + qs "6.7.0" + raw-body "2.4.0" + type-is "~1.6.17" + +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@^2.3.1: + 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" + +braces@^3.0.1, braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +browser-process-hrtime@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626" + integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +browserslist@^4.14.5, browserslist@^4.16.6: + version "4.16.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" + integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== + dependencies: + caniuse-lite "^1.0.30001219" + colorette "^1.2.2" + electron-to-chromium "^1.3.723" + escalade "^3.1.1" + node-releases "^1.1.71" + +bs-logger@0.x: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + +buffer-from@1.x, buffer-from@^1.0.0, buffer-from@^1.1.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@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + +bunyan@^1.8.12: + version "1.8.15" + resolved "https://registry.yarnpkg.com/bunyan/-/bunyan-1.8.15.tgz#8ce34ca908a17d0776576ca1b2f6cbd916e93b46" + integrity sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig== + optionalDependencies: + dtrace-provider "~0.8" + moment "^2.19.3" + mv "~2" + safe-json-stringify "~1" + +busboy@^0.2.11: + version "0.2.14" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453" + integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM= + dependencies: + dicer "0.2.5" + readable-stream "1.1.x" + +bytes@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" + integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== + +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" + +caching-transform@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/caching-transform/-/caching-transform-4.0.0.tgz#00d297a4206d71e2163c39eaffa8157ac0651f0f" + integrity sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA== + dependencies: + hasha "^5.0.0" + make-dir "^3.0.0" + package-hash "^4.0.0" + write-file-atomic "^3.0.0" + +call-bind@^1.0.0, call-bind@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + +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== + +camelcase@^5.0.0, 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== + +camelcase@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" + integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== + +caniuse-lite@^1.0.30001219: + version "1.0.30001237" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001237.tgz#4b7783661515b8e7151fc6376cfd97f0e427b9e5" + integrity sha512-pDHgRndit6p1NR2GhzMbQ6CkRrp4VKuSsqbcLeOQppYPKOYkKT/6ZvZDvKJUqcmtyWIAHuZq3SVS2vc1egCZzw== + +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" + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +chai@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49" + integrity sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.2" + deep-eql "^3.0.1" + get-func-name "^2.0.0" + pathval "^1.1.1" + type-detect "^4.0.5" + +chalk@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^1.1.1: + 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" + +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, 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@^4.0.0, chalk@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.1.tgz#c80b3fab28bf6371e6863325eee67e618b77e6ad" + integrity sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + +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== + +charenc@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= + +check-error@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" + integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= + +chokidar@3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" + integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.5.0" + optionalDependencies: + fsevents "~2.3.1" + +chokidar@^3.4.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" + integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chownr@^1.1.1, chownr@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +chrome-trace-event@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" + integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== + +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== + +cjs-module-lexer@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz#4186fcca0eae175970aee870b9fe2d6cf8d5655f" + integrity sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw== + +class-transformer@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.2.3.tgz#598c92ca71dcca73f91ccb875d74a3847ccfa32d" + integrity sha512-qsP+0xoavpOlJHuYsQJsN58HXSl8Jvveo+T37rEvCEeRfMWoytAyR0Ua/YsFgpM6AZYZ/og2PJwArwzJl1aXtQ== + +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" + +class-validator@^0.12.2: + version "0.12.2" + resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.12.2.tgz#2ceb72f88873e9c714cf5f9c278cbc71f6f6c8ef" + integrity sha512-TDzPzp8BmpsbPhQpccB3jMUE/3pK0TyqamrK0kcx+ZeFytMA+O6q87JZZGObHHnoo9GM8vl/JppIyKWeEA/EVw== + dependencies: + "@types/validator" "13.0.0" + google-libphonenumber "^3.2.8" + tslib ">=1.9.0" + validator "13.0.0" + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +cli-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.0.tgz#11ecfb58a79278cf6035a60c54e338f9d837897c" + integrity sha512-a0VZ8LeraW0jTuCkuAGMNufareGHhyZU9z8OGsW0gXd1hZGi1SRuNRXdbGkraBBKnhyUhyebFWnRbp+dIn0f0A== + dependencies: + ansi-regex "^2.1.1" + d "^1.0.1" + es5-ext "^0.10.51" + es6-iterator "^2.0.3" + memoizee "^0.4.14" + timers-ext "^0.1.7" + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-highlight@^2.1.10: + version "2.1.11" + resolved "https://registry.yarnpkg.com/cli-highlight/-/cli-highlight-2.1.11.tgz#49736fa452f0aaf4fae580e30acb26828d2dc1bf" + integrity sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg== + dependencies: + chalk "^4.0.0" + highlight.js "^10.7.1" + mz "^2.4.0" + parse5 "^5.1.1" + parse5-htmlparser2-tree-adapter "^6.0.0" + yargs "^16.0.0" + +cli-spinners@^2.5.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.6.0.tgz#36c7dc98fb6a9a76bd6238ec3f77e2425627e939" + integrity sha512-t+4/y50K/+4xcCRosKkA7W4gTr1MySvLV0q+PxmG7FJ5g+66ChKurYjxBCjHggHH3HA5Hh9cy+lcUGWDqVH+4Q== + +cli-table3@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202" + integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw== + dependencies: + object-assign "^4.1.0" + string-width "^2.1.1" + optionalDependencies: + colors "^1.1.2" + +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + +cliui@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" + integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA== + dependencies: + string-width "^3.1.0" + strip-ansi "^5.2.0" + wrap-ansi "^5.1.0" + +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= + +cluster-key-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d" + integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw== + +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= + +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= + +collect-v8-coverage@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" + integrity sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg== + +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-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +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, color-name@~1.1.4: + 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.5" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.5.tgz#65474a8f0e7439625f3d27a6a19d89fc45223014" + integrity sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@3.0.x: + version "3.0.0" + resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" + integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.2" + +colorette@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" + integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== + +colors@^1.1.2, colors@^1.2.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + +colorspace@1.1.x: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5" + integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ== + dependencies: + color "3.0.x" + text-hex "1.0.x" + +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +commander@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== + +commander@^2.20.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +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.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + +component-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/component-type/-/component-type-1.2.1.tgz#8a47901700238e4fc32269771230226f24b415a9" + integrity sha1-ikeQFwAjjk/DIml3EjAibyS0Fak= + +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.2: + 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" + +concurrently@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-5.3.0.tgz#7500de6410d043c912b2da27de3202cb489b1e7b" + integrity sha512-8MhqOB6PWlBfA2vJ8a0bSFKATOdWlHiQlk11IfmQBPaHVP8oP2gsh2MObE6UR3hqDHqvaIvLTyceNW6obVuFHQ== + dependencies: + chalk "^2.4.2" + date-fns "^2.0.1" + lodash "^4.17.15" + read-pkg "^4.0.1" + rxjs "^6.5.2" + spawn-command "^0.0.2-1" + supports-color "^6.1.0" + tree-kill "^1.2.2" + yargs "^13.3.0" + +confusing-browser-globals@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz#30d1e7f3d1b882b25ec4933d1d1adac353d20a59" + integrity sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA== + +consola@^2.15.0: + version "2.15.3" + resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" + integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw== + +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= + +content-disposition@0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" + integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g== + dependencies: + safe-buffer "5.1.2" + +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.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" + integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== + 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.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" + integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== + +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-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-util-is@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +cors@2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cosmiconfig@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" + integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.1.0" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.7.2" + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +cross-env@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" + integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw== + dependencies: + cross-spawn "^7.0.1" + +cross-spawn@^6.0.0: + 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@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +crypt@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= + +cssom@^0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" + integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw== + +cssom@~0.3.6: + version "0.3.8" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" + integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== + +cssstyle@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" + integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== + dependencies: + cssom "~0.3.6" + +d@1, d@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" + integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA== + dependencies: + es5-ext "^0.10.50" + type "^1.0.1" + +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@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" + integrity sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ== + dependencies: + abab "^2.0.3" + whatwg-mimetype "^2.3.0" + whatwg-url "^8.0.0" + +date-fns@^2.0.1: + version "2.22.1" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.22.1.tgz#1e5af959831ebb1d82992bf67b765052d8f0efc4" + integrity sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg== + +debug@2.6.9, debug@^2.2.0, debug@^2.3.3, 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@4, debug@4.3.1, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + +debug@^3.1.0, debug@^3.2.6, debug@^3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +decimal.js@^10.2.1: + version "10.2.1" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.1.tgz#238ae7b0f0c793d3e3cea410108b35a2c01426a3" + integrity sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw== + +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= + +decompress-response@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" + integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== + dependencies: + mimic-response "^2.0.0" + +deep-eql@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df" + integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw== + dependencies: + type-detect "^4.0.0" + +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, 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= + +deepmerge@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" + integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== + +default-require-extensions@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-3.0.0.tgz#e03f93aac9b2b6443fc52e5e4a37b3ad9ad8df96" + integrity sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg== + dependencies: + strip-bom "^4.0.0" + +defaults@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + integrity sha1-xlYFHpgX2f8I7YgUd/P+QBnz730= + dependencies: + clone "^1.0.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" + +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= + +denque@^1.1.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de" + integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ== + +depd@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" + integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= + +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-libc@^1.0.2, detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + +detect-newline@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" + integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== + +dicer@0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f" + integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8= + dependencies: + readable-stream "1.1.x" + streamsearch "0.1.2" + +diff-sequences@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5" + integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew== + +diff-sequences@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" + integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== + +diff@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + +diff@^3.1.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" + integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.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" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +domexception@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" + integrity sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg== + dependencies: + webidl-conversions "^5.0.0" + +dotenv@^8.2.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" + integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g== + +dtrace-provider@~0.8: + version "0.8.8" + resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.8.tgz#2996d5490c37e1347be263b423ed7b297fb0d97e" + integrity sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg== + dependencies: + nan "^2.14.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.723: + version "1.3.752" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.752.tgz#0728587f1b9b970ec9ffad932496429aef750d09" + integrity sha512-2Tg+7jSl3oPxgsBsWKh5H83QazTkmWG/cnNwJplmyZc7KcN61+I10oUgaXSVk/NwfvN3BdkKDR4FYuRBQQ2v0A== + +emittery@^0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.2.tgz#25595908e13af0f5674ab419396e2fb394cdfa82" + integrity sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ== + +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== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +enhanced-resolve@^4.0.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz#2f3cfd84dbe3b487f18f2db2ef1e064a571ca5ec" + integrity sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg== + dependencies: + graceful-fs "^4.1.2" + memory-fs "^0.5.0" + tapable "^1.0.0" + +enhanced-resolve@^5.7.0: + version "5.8.2" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz#15ddc779345cbb73e97c611cd00c01c1e7bf4d8b" + integrity sha512-F27oB3WuHDzvR2DOGNTaYy0D5o0cnrv8TeI482VM4kYgQd/FT9lUQwuNsJ0oOHtBUq7eiW5ytqzp7nBFknL+GA== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + +enquirer@^2.3.5: + version "2.3.6" + resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" + integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== + dependencies: + ansi-colors "^4.1.1" + +errno@^0.1.3: + version "0.1.8" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" + integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A== + dependencies: + prr "~1.0.1" + +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.18.0-next.1, es-abstract@^1.18.0-next.2, es-abstract@^1.18.2: + version "1.18.3" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.3.tgz#25c4c3380a27aa203c44b2b685bba94da31b63e0" + integrity sha512-nQIr12dxV7SSxE6r6f1l3DtAeEYdsGpps13dR0TwJg1S8gyp4ZPgy3FZcHBgbiQqnoqSTb+oC+kO4UQ0C/J8vw== + dependencies: + call-bind "^1.0.2" + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + get-intrinsic "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.2" + is-callable "^1.2.3" + is-negative-zero "^2.0.1" + is-regex "^1.1.3" + is-string "^1.0.6" + object-inspect "^1.10.3" + object-keys "^1.1.1" + object.assign "^4.1.2" + string.prototype.trimend "^1.0.4" + string.prototype.trimstart "^1.0.4" + unbox-primitive "^1.0.1" + +es-module-lexer@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.4.1.tgz#dda8c6a14d8f340a24e34331e0fab0cb50438e0e" + integrity sha512-ooYciCUtfw6/d2w56UVeqHPcoCFAiJdz5XOkYpv/Txl1HMUozpXjz/2RIQgqwKdXNDPSF1W7mJCFse3G+HDyAA== + +es-to-primitive@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" + integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.51, es5-ext@^0.10.53, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: + version "0.10.53" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.53.tgz#93c5a3acfdbef275220ad72644ad02ee18368de1" + integrity sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q== + dependencies: + es6-iterator "~2.0.3" + es6-symbol "~3.1.3" + next-tick "~1.0.0" + +es6-error@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" + integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== + +es6-iterator@^2.0.3, es6-iterator@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + +es6-symbol@^3.1.1, es6-symbol@~3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18" + integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA== + dependencies: + d "^1.0.1" + ext "^1.1.2" + +es6-weak-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" + integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== + dependencies: + d "1" + es5-ext "^0.10.46" + es6-iterator "^2.0.3" + es6-symbol "^3.1.1" + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +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@4.0.0, escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +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= + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escodegen@^1.8.1: + version "1.14.3" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" + integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== + dependencies: + esprima "^4.0.1" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +escodegen@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd" + integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +eslint-config-airbnb-base@^14.2.0, eslint-config-airbnb-base@^14.2.1: + version "14.2.1" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz#8a2eb38455dc5a312550193b319cdaeef042cd1e" + integrity sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA== + dependencies: + confusing-browser-globals "^1.0.10" + object.assign "^4.1.2" + object.entries "^1.1.2" + +eslint-config-airbnb-typescript@^12.3.1: + version "12.3.1" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-12.3.1.tgz#83ab40d76402c208eb08516260d1d6fac8f8acbc" + integrity sha512-ql/Pe6/hppYuRp4m3iPaHJqkBB7dgeEmGPQ6X0UNmrQOfTF+dXw29/ZjU2kQ6RDoLxaxOA+Xqv07Vbef6oVTWw== + dependencies: + "@typescript-eslint/parser" "^4.4.1" + eslint-config-airbnb "^18.2.0" + eslint-config-airbnb-base "^14.2.0" + +eslint-config-airbnb@^18.2.0: + version "18.2.1" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb/-/eslint-config-airbnb-18.2.1.tgz#b7fe2b42f9f8173e825b73c8014b592e449c98d9" + integrity sha512-glZNDEZ36VdlZWoxn/bUR1r/sdFKPd1mHPbqUtkctgNG4yT2DLLtJ3D+yCV+jzZCc2V1nBVkmdknOJBZ5Hc0fg== + dependencies: + eslint-config-airbnb-base "^14.2.1" + object.assign "^4.1.2" + object.entries "^1.1.2" + +eslint-config-prettier@^6.10.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.15.0.tgz#7f93f6cb7d45a92f1537a70ecc06366e1ac6fed9" + integrity sha512-a1+kOYLR8wMGustcgAjdydMsQ2A/2ipRPwRKUmfYaSxc9ZPcrku080Ctl6zrZzZNs/U82MjSv+qKREkoq3bJaw== + dependencies: + get-stdin "^6.0.0" + +eslint-import-resolver-node@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717" + integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA== + dependencies: + debug "^2.6.9" + resolve "^1.13.1" + +eslint-module-utils@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.1.tgz#b51be1e473dd0de1c5ea638e22429c2490ea8233" + integrity sha512-ZXI9B8cxAJIH4nfkhTwcRTEAnrVfobYqwjWy/QMCZ8rHkZHFjf9yO4BzpiF9kCSfNlMG54eKigISHpX0+AaT4A== + dependencies: + debug "^3.2.7" + pkg-dir "^2.0.0" + +eslint-plugin-import@^2.20.1: + version "2.23.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.23.4.tgz#8dceb1ed6b73e46e50ec9a5bb2411b645e7d3d97" + integrity sha512-6/wP8zZRsnQFiR3iaPFgh5ImVRM1WN5NUWfTIRqwOdeiGJlBcSk82o1FEVq8yXmy4lkIzTo7YhHCIxlU/2HyEQ== + dependencies: + array-includes "^3.1.3" + array.prototype.flat "^1.2.4" + debug "^2.6.9" + doctrine "^2.1.0" + eslint-import-resolver-node "^0.3.4" + eslint-module-utils "^2.6.1" + find-up "^2.0.0" + has "^1.0.3" + is-core-module "^2.4.0" + minimatch "^3.0.4" + object.values "^1.1.3" + pkg-up "^2.0.0" + read-pkg-up "^3.0.0" + resolve "^1.20.0" + tsconfig-paths "^3.9.0" + +eslint-plugin-sonarjs@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.9.1.tgz#a3c63ab0d267bfb69863159e42c8081b01fd3ac6" + integrity sha512-KKFofk1LPjGHWeAZijYWv32c/C4mz+OAeBNVxhxHu1hknrTOhu415MWC8qKdAdsmOlBPShs9evM4mI1o7MNMhw== + +eslint-scope@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +eslint-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" + integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== + dependencies: + eslint-visitor-keys "^1.1.0" + +eslint-utils@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672" + integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== + dependencies: + eslint-visitor-keys "^2.0.0" + +eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" + integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== + +eslint-visitor-keys@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" + integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== + +eslint@^7.1.0: + version "7.28.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.28.0.tgz#435aa17a0b82c13bb2be9d51408b617e49c1e820" + integrity sha512-UMfH0VSjP0G4p3EWirscJEQ/cHqnT/iuH6oNZOB94nBjWbMnhGEPxsZm1eyIW0C/9jLI0Fow4W5DXLjEI7mn1g== + dependencies: + "@babel/code-frame" "7.12.11" + "@eslint/eslintrc" "^0.4.2" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.0.1" + doctrine "^3.0.0" + enquirer "^2.3.5" + escape-string-regexp "^4.0.0" + eslint-scope "^5.1.1" + eslint-utils "^2.1.0" + eslint-visitor-keys "^2.0.0" + espree "^7.3.1" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + functional-red-black-tree "^1.0.1" + glob-parent "^5.1.2" + globals "^13.6.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^3.13.1" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.0.4" + natural-compare "^1.4.0" + optionator "^0.9.1" + progress "^2.0.0" + regexpp "^3.1.0" + semver "^7.2.1" + strip-ansi "^6.0.0" + strip-json-comments "^3.1.0" + table "^6.0.9" + text-table "^0.2.0" + v8-compile-cache "^2.0.3" + +espree@^7.3.0, espree@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" + integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== + dependencies: + acorn "^7.4.0" + acorn-jsx "^5.3.1" + eslint-visitor-keys "^1.3.0" + +esprima@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.2.2.tgz#76a0fd66fcfe154fd292667dc264019750b1657b" + integrity sha1-dqD9Zvz+FU/SkmZ9wmQBl1CxZXs= + +esprima@^4.0.0, esprima@^4.0.1: + 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.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5" + integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1, estraverse@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880" + integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +etag@~1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + +event-emitter@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= + dependencies: + d "1" + es5-ext "~0.10.14" + +eventemitter2@6.4.4: + version "6.4.4" + resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.4.tgz#aa96e8275c4dbeb017a5d0e03780c65612a1202b" + integrity sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw== + +events@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +exec-sh@^0.3.2: + version "0.3.6" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" + integrity sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w== + +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" + +execa@^4.0.0, execa@^4.0.2: + version "4.1.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" + integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + +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@^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-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + +expect@^24.8.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-24.9.0.tgz#b75165b4817074fa4a157794f46fe9f1ba15b6ca" + integrity sha512-wvVAx8XIol3Z5m9zvZXiyZOQ+sRJqNTIm6sGjdWlaZIeupQGO3WbYI+15D/AmEwZywL6wtJkbAbJtzkOfBuR0Q== + dependencies: + "@jest/types" "^24.9.0" + ansi-styles "^3.2.0" + jest-get-type "^24.9.0" + jest-matcher-utils "^24.9.0" + jest-message-util "^24.9.0" + jest-regex-util "^24.9.0" + +expect@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/expect/-/expect-26.6.2.tgz#c6b996bf26bf3fe18b67b2d0f51fc981ba934417" + integrity sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA== + dependencies: + "@jest/types" "^26.6.2" + ansi-styles "^4.0.0" + jest-get-type "^26.3.0" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-regex-util "^26.0.0" + +express@4.17.1, express@^4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" + integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== + dependencies: + accepts "~1.3.7" + array-flatten "1.1.1" + body-parser "1.19.0" + content-disposition "0.5.3" + content-type "~1.0.4" + cookie "0.4.0" + 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.2" + fresh "0.5.2" + merge-descriptors "1.0.1" + methods "~1.1.2" + on-finished "~2.3.0" + parseurl "~1.3.3" + path-to-regexp "0.1.7" + proxy-addr "~2.0.5" + qs "6.7.0" + range-parser "~1.2.1" + safe-buffer "5.1.2" + send "0.17.1" + serve-static "1.14.1" + setprototypeof "1.1.1" + statuses "~1.5.0" + type-is "~1.6.18" + utils-merge "1.0.1" + vary "~1.1.2" + +ext@^1.1.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ext/-/ext-1.4.0.tgz#89ae7a07158f79d35517882904324077e4379244" + integrity sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A== + dependencies: + type "^2.0.0" + +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.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" + integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + +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@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.1.1: + version "3.2.5" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.5.tgz#7939af2a656de79a4f1901903ee8adcaa7cb9661" + integrity sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.0" + merge2 "^1.3.0" + micromatch "^4.0.2" + picomatch "^2.2.1" + +fast-json-stable-stringify@2.1.0, fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fast-safe-stringify@2.0.7, fast-safe-stringify@^2.0.4, fast-safe-stringify@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" + integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== + +fastq@^1.6.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.11.0.tgz#bb9fb955a07130a918eb63c1f5161cc32a5d0858" + integrity sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g== + dependencies: + reusify "^1.0.4" + +fb-watchman@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" + integrity sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg== + dependencies: + bser "2.1.1" + +fecha@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.1.tgz#0a83ad8f86ef62a091e22bb5a039cd03d23eecce" + integrity sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q== + +fengari-interop@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/fengari-interop/-/fengari-interop-0.1.2.tgz#f7731dcdd2ff4449073fb7ac3c451a8841ce1e87" + integrity sha512-8iTvaByZVoi+lQJhHH9vC+c/Yaok9CwOqNQZN6JrVpjmWwW4dDkeblBXhnHC+BoI6eF4Cy5NKW3z6ICEjvgywQ== + +fengari@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/fengari/-/fengari-0.1.4.tgz#72416693cd9e43bd7d809d7829ddc0578b78b0bb" + integrity sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g== + dependencies: + readline-sync "^1.4.9" + sprintf-js "^1.1.1" + tmp "^0.0.33" + +figlet@^1.1.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/figlet/-/figlet-1.5.0.tgz#2db4d00a584e5155a96080632db919213c3e003c" + integrity sha512-ZQJM4aifMpz6H19AW1VqvZ7l4pOE9p7i/3LyxgO2kp+PO/VcDYNqIHEMtkccqIhTXMKci4kjueJr/iCQEaT/Ww== + +figures@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + dependencies: + escape-string-regexp "^1.0.5" + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +file-stream-rotator@^0.5.7: + version "0.5.7" + resolved "https://registry.yarnpkg.com/file-stream-rotator/-/file-stream-rotator-0.5.7.tgz#868a2e5966f7640a17dd86eda0e4467c089f6286" + integrity sha512-VYb3HZ/GiAGUCrfeakO8Mp54YGswNUHvL7P09WQcXAJNSj3iQ5QraYSp3cIn1MUyw6uzfgN/EFOarCNa4JvUHQ== + dependencies: + moment "^2.11.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" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" + integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +find-cache-dir@^3.2.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880" + integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + +find-up@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.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" + +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@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" + integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== + dependencies: + flatted "^3.1.0" + rimraf "^3.0.2" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +flatted@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" + integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== + +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + +follow-redirects@^1.10.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43" + integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg== + +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= + +foreground-child@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" + integrity sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^3.0.2" + +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@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.2.0.tgz#d13af02e24d1b17f769af6bdf41c1e849e1615cc" + integrity sha512-DTNbOhq6lRdjYprukX54JMeYJgQ0zMow+R5BMLwWxEX2NAXthIkwnV8DBmsWjwNLSUItKZM4TCCJbtgrtKBu2Q== + dependencies: + "@babel/code-frame" "^7.8.3" + "@types/json-schema" "^7.0.5" + chalk "^4.1.0" + chokidar "^3.4.2" + cosmiconfig "^6.0.0" + deepmerge "^4.2.2" + fs-extra "^9.0.0" + memfs "^3.1.2" + minimatch "^3.0.4" + schema-utils "2.7.0" + semver "^7.3.2" + tapable "^1.0.0" + +form-data@^2.3.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +form-data@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" + integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +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.2" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" + integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +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= + +fromentries@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fromentries/-/fromentries-1.3.2.tgz#e4bca6808816bf8f93b52750f1127f5a6fd86e3a" + integrity sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg== + +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fs-extra@9.1.0, fs-extra@^9.0.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fs-minipass@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" + integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== + dependencies: + minipass "^2.6.0" + +fs-monkey@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3" + integrity sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q== + +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@^2.1.2, fsevents@~2.3.1, fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +fstream@^1.0.0, fstream@^1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" + integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "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== + +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" + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.1, get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-func-name@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" + integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= + +get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" + integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +get-stdin@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" + integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== + +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-stream@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + 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" + +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= + +glob-parent@^5.1.0, glob-parent@^5.1.2, glob-parent@~5.1.0, glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + +glob@7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + 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" + +glob@^6.0.1: + version "6.0.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" + integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI= + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: + version "7.1.7" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" + integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== + 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" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globals@^13.6.0, globals@^13.9.0: + version "13.9.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.9.0.tgz#4bf2bf635b334a173fb1daf7c5e6b218ecdc06cb" + integrity sha512-74/FduwI/JaIrr1H8e71UbDE+5x7pIPs1C2rrwC52SszOo043CsWOZEMW7o2Y58xwm9b+0RBKDxY5n2sUpEFxA== + dependencies: + type-fest "^0.20.2" + +globby@^11.0.3: + version "11.0.4" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5" + integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + +google-libphonenumber@^3.2.8: + version "3.2.21" + resolved "https://registry.yarnpkg.com/google-libphonenumber/-/google-libphonenumber-3.2.21.tgz#6c01e037ef580dd5c580e6bf3129aa6c1581969f" + integrity sha512-d8dMePLPIZXHGEvyGM4PTEPBxXC29mhXtqruD11iZd9KzyKb216kJuBPZq6m3BTmiI5ZiIb4epzrZsatRJ5ZaA== + +graceful-fs@^4.1.15, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: + version "4.2.6" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" + integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== + +graceful-fs@^4.1.2: + version "4.2.8" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" + integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== + +growl@1.10.5: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + +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= + +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.3: + version "5.1.5" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" + integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== + dependencies: + ajv "^6.12.3" + har-schema "^2.0.0" + +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-bigints@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" + integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== + +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-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-symbols@^1.0.1, has-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" + integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== + +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.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" + +hasha@^5.0.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/hasha/-/hasha-5.2.2.tgz#a48477989b3b327aea3c04f53096d816d97522a1" + integrity sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ== + dependencies: + is-stream "^2.0.0" + type-fest "^0.8.0" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +highlight.js@^10.7.1: + version "10.7.3" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" + integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== + +hosted-git-info@^2.1.4: + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== + +html-encoding-sniffer@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" + integrity sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ== + dependencies: + whatwg-encoding "^1.0.5" + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +http-errors@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" + integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg== + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +http-errors@~1.7.2: + version "1.7.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" + integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + +http-proxy-agent@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" + integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== + dependencies: + "@tootallnate/once" "1" + agent-base "6" + debug "4" + +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-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + dependencies: + agent-base "6" + debug "4" + +human-signals@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" + integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== + +iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4: + 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" + +ieee754@^1.1.13, ieee754@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +ignore-walk@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.4.tgz#c9a09f69b7c7b479a5d74ac1a3c0d4236d2a6335" + integrity sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ== + dependencies: + minimatch "^3.0.4" + +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== + +ignore@^5.1.4: + version "5.1.8" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" + integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== + +import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-local@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.0.2.tgz#a8cfd0431d1de4a2199703d003e3e62364fa6db6" + integrity sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.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= + +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +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.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +inquirer@7.3.3: + version "7.3.3" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" + integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.19" + mute-stream "0.0.8" + run-async "^2.4.0" + rxjs "^6.6.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + +interpret@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== + +ioredis-mock@^5.5.4: + version "5.6.0" + resolved "https://registry.yarnpkg.com/ioredis-mock/-/ioredis-mock-5.6.0.tgz#f60f9fbc3a53b50f567be9369e2b211ed52c0653" + integrity sha512-Ow+tyKdijg/gA2gSEv7lq8dLp6bO7FnwDXbJ9as37NF23XNRGMLzBc7ITaqMydfrbTodWnLcE2lKEaBs7SBpyA== + dependencies: + fengari "^0.1.4" + fengari-interop "^0.1.2" + lodash "^4.17.21" + standard-as-callback "^2.1.0" + +ioredis@^4.27.1: + version "4.27.6" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.27.6.tgz#a53d427d3fe75fbd10ed7ad150ce00559df8dcf8" + integrity sha512-6W3ZHMbpCa8ByMyC1LJGOi7P2WiOKP9B3resoZOVLDhi+6dDBOW+KNsRq3yI36Hmnb2sifCxHX+YSarTeXh48A== + dependencies: + cluster-key-slot "^1.1.0" + debug "^4.3.1" + denque "^1.1.0" + lodash.defaults "^4.2.0" + lodash.flatten "^4.4.0" + p-map "^2.1.0" + redis-commands "1.7.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +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-bigint@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.2.tgz#ffb381442503235ad245ea89e45b3dbff040ee5a" + integrity sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-boolean-object@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.1.tgz#3c0878f035cb821228d350d2e1e36719716a3de8" + integrity sha512-bXdQWkECBUIAcCkeH1unwJLIpZYaa5VvuygSyS/c2lf719mTKZDU5UdDRlpd01UjADgmW8RfqaP+mRaVPdr/Ng== + dependencies: + call-bind "^1.0.2" + +is-buffer@^1.1.5, is-buffer@~1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-callable@^1.1.4, is-callable@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" + integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== + +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-core-module@^2.2.0, is-core-module@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.4.0.tgz#8e9fc8e15027b011418026e98f0e6f4d86305cc1" + integrity sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A== + dependencies: + has "^1.0.3" + +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.4" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.4.tgz#550cfcc03afada05eea3dd30981c7b09551f73e5" + integrity sha512-/b4ZVsG7Z5XVtIxs/h9W8nvfLgSAyKYdtGWQLbqy6jA1icmgjf8WCoTKgeS4wy5tYaPePouzFMANbnj94c2Z+A== + +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-docker@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + +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@^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-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-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: + 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-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + +is-negative-zero@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== + +is-number-object@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.5.tgz#6edfaeed7950cff19afedce9fbfca9ee6dd289eb" + integrity sha512-RU0lI/n95pMoUKu9v1BZP5MBcZuNSVJkMkAG2dJqC4z2GlkGUNeH68SuHuBKBD/XFe+LHZ+f9BKkLET60Niedw== + +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@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +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-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + +is-promise@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" + integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== + +is-regex@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.3.tgz#d029f9aff6448b93ebbe3f33dac71511fdcbef9f" + integrity sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ== + dependencies: + call-bind "^1.0.2" + has-symbols "^1.0.2" + +is-retry-allowed@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" + integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg== + +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-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3" + integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw== + +is-string@^1.0.5, is-string@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.6.tgz#3fe5d5992fb0d93404f32584d4b0179a71b54a5f" + integrity sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w== + +is-symbol@^1.0.2, is-symbol@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" + integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== + dependencies: + has-symbols "^1.0.2" + +is-typedarray@^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-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +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@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + +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: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +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= + +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-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.0.0-alpha.1: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz#f5944a37c70b550b02a78a5c3b2055b280cec8ec" + integrity sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg== + +istanbul-lib-hook@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz#8f84c9434888cc6b1d0a9d7092a76d239ebf0cc6" + integrity sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ== + dependencies: + append-transform "^2.0.0" + +istanbul-lib-instrument@^4.0.0, istanbul-lib-instrument@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz#873c6fff897450118222774696a3f28902d77c1d" + integrity sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== + dependencies: + "@babel/core" "^7.7.5" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.0.0" + semver "^6.3.0" + +istanbul-lib-processinfo@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz#e1426514662244b2f25df728e8fd1ba35fe53b9c" + integrity sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw== + dependencies: + archy "^1.0.0" + cross-spawn "^7.0.0" + istanbul-lib-coverage "^3.0.0-alpha.1" + make-dir "^3.0.0" + p-map "^3.0.0" + rimraf "^3.0.0" + uuid "^3.3.3" + +istanbul-lib-report@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6" + integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^3.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz#75743ce6d96bb86dc7ee4352cf6366a23f0b1ad9" + integrity sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + +istanbul-reports@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.2.tgz#d593210e5000683750cb09fc0644e4b6e27fd53b" + integrity sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +iterare@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/iterare/-/iterare-1.2.1.tgz#139c400ff7363690e33abffa33cbba8920f00042" + integrity sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q== + +jest-changed-files@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.6.2.tgz#f6198479e1cc66f22f9ae1e22acaa0b429c042d0" + integrity sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ== + dependencies: + "@jest/types" "^26.6.2" + execa "^4.0.0" + throat "^5.0.0" + +jest-cli@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-26.6.3.tgz#43117cfef24bc4cd691a174a8796a532e135e92a" + integrity sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg== + dependencies: + "@jest/core" "^26.6.3" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.4" + import-local "^3.0.2" + is-ci "^2.0.0" + jest-config "^26.6.3" + jest-util "^26.6.2" + jest-validate "^26.6.2" + prompts "^2.0.1" + yargs "^15.4.1" + +jest-config@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-26.6.3.tgz#64f41444eef9eb03dc51d5c53b75c8c71f645349" + integrity sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg== + dependencies: + "@babel/core" "^7.1.0" + "@jest/test-sequencer" "^26.6.3" + "@jest/types" "^26.6.2" + babel-jest "^26.6.3" + chalk "^4.0.0" + deepmerge "^4.2.2" + glob "^7.1.1" + graceful-fs "^4.2.4" + jest-environment-jsdom "^26.6.2" + jest-environment-node "^26.6.2" + jest-get-type "^26.3.0" + jest-jasmine2 "^26.6.3" + jest-regex-util "^26.0.0" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" + micromatch "^4.0.2" + pretty-format "^26.6.2" + +jest-diff@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-24.9.0.tgz#931b7d0d5778a1baf7452cb816e325e3724055da" + integrity sha512-qMfrTs8AdJE2iqrTp0hzh7kTd2PQWrsFyj9tORoKmu32xjPjeE4NyjVRDz8ybYwqS2ik8N4hsIpiVTyFeo2lBQ== + dependencies: + chalk "^2.0.1" + diff-sequences "^24.9.0" + jest-get-type "^24.9.0" + pretty-format "^24.9.0" + +jest-diff@^26.0.0, jest-diff@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" + integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== + dependencies: + chalk "^4.0.0" + diff-sequences "^26.6.2" + jest-get-type "^26.3.0" + pretty-format "^26.6.2" + +jest-docblock@^26.0.0: + version "26.0.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" + integrity sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w== + dependencies: + detect-newline "^3.0.0" + +jest-each@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-26.6.2.tgz#02526438a77a67401c8a6382dfe5999952c167cb" + integrity sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A== + dependencies: + "@jest/types" "^26.6.2" + chalk "^4.0.0" + jest-get-type "^26.3.0" + jest-util "^26.6.2" + pretty-format "^26.6.2" + +jest-environment-jsdom@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz#78d09fe9cf019a357009b9b7e1f101d23bd1da3e" + integrity sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q== + dependencies: + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + jest-mock "^26.6.2" + jest-util "^26.6.2" + jsdom "^16.4.0" + +jest-environment-node@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-26.6.2.tgz#824e4c7fb4944646356f11ac75b229b0035f2b0c" + integrity sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag== + dependencies: + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + jest-mock "^26.6.2" + jest-util "^26.6.2" + +jest-get-type@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.9.0.tgz#1684a0c8a50f2e4901b6644ae861f579eed2ef0e" + integrity sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q== + +jest-get-type@^26.3.0: + version "26.3.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" + integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== + +jest-haste-map@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" + integrity sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w== + dependencies: + "@jest/types" "^26.6.2" + "@types/graceful-fs" "^4.1.2" + "@types/node" "*" + anymatch "^3.0.3" + fb-watchman "^2.0.0" + graceful-fs "^4.2.4" + jest-regex-util "^26.0.0" + jest-serializer "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" + micromatch "^4.0.2" + sane "^4.0.3" + walker "^1.0.7" + optionalDependencies: + fsevents "^2.1.2" + +jest-jasmine2@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz#adc3cf915deacb5212c93b9f3547cd12958f2edd" + integrity sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg== + dependencies: + "@babel/traverse" "^7.1.0" + "@jest/environment" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + co "^4.6.0" + expect "^26.6.2" + is-generator-fn "^2.0.0" + jest-each "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + pretty-format "^26.6.2" + throat "^5.0.0" + +jest-leak-detector@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz#7717cf118b92238f2eba65054c8a0c9c653a91af" + integrity sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg== + dependencies: + jest-get-type "^26.3.0" + pretty-format "^26.6.2" + +jest-matcher-utils@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz#f5b3661d5e628dffe6dd65251dfdae0e87c3a073" + integrity sha512-OZz2IXsu6eaiMAwe67c1T+5tUAtQyQx27/EMEkbFAGiw52tB9em+uGbzpcgYVpA8wl0hlxKPZxrly4CXU/GjHA== + dependencies: + chalk "^2.0.1" + jest-diff "^24.9.0" + jest-get-type "^24.9.0" + pretty-format "^24.9.0" + +jest-matcher-utils@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz#8e6fd6e863c8b2d31ac6472eeb237bc595e53e7a" + integrity sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw== + dependencies: + chalk "^4.0.0" + jest-diff "^26.6.2" + jest-get-type "^26.3.0" + pretty-format "^26.6.2" + +jest-message-util@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.9.0.tgz#527f54a1e380f5e202a8d1149b0ec872f43119e3" + integrity sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw== + dependencies: + "@babel/code-frame" "^7.0.0" + "@jest/test-result" "^24.9.0" + "@jest/types" "^24.9.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-message-util@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.6.2.tgz#58173744ad6fc0506b5d21150b9be56ef001ca07" + integrity sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA== + dependencies: + "@babel/code-frame" "^7.0.0" + "@jest/types" "^26.6.2" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.4" + micromatch "^4.0.2" + pretty-format "^26.6.2" + slash "^3.0.0" + stack-utils "^2.0.2" + +jest-mock@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.6.2.tgz#d6cb712b041ed47fe0d9b6fc3474bc6543feb302" + integrity sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew== + dependencies: + "@jest/types" "^26.6.2" + "@types/node" "*" + +jest-pnp-resolver@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" + integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== + +jest-regex-util@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-24.9.0.tgz#c13fb3380bde22bf6575432c493ea8fe37965636" + integrity sha512-05Cmb6CuxaA+Ys6fjr3PhvV3bGQmO+2p2La4hFbU+W5uOc479f7FdLXUWXw4pYMAhhSZIuKHwSXSu6CsSBAXQA== + +jest-regex-util@^26.0.0: + version "26.0.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28" + integrity sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A== + +jest-resolve-dependencies@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz#6680859ee5d22ee5dcd961fe4871f59f4c784fb6" + integrity sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg== + dependencies: + "@jest/types" "^26.6.2" + jest-regex-util "^26.0.0" + jest-snapshot "^26.6.2" + +jest-resolve@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-26.6.2.tgz#a3ab1517217f469b504f1b56603c5bb541fbb507" + integrity sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ== + dependencies: + "@jest/types" "^26.6.2" + chalk "^4.0.0" + graceful-fs "^4.2.4" + jest-pnp-resolver "^1.2.2" + jest-util "^26.6.2" + read-pkg-up "^7.0.1" + resolve "^1.18.1" + slash "^3.0.0" + +jest-runner@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-26.6.3.tgz#2d1fed3d46e10f233fd1dbd3bfaa3fe8924be159" + integrity sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ== + dependencies: + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + emittery "^0.7.1" + exit "^0.1.2" + graceful-fs "^4.2.4" + jest-config "^26.6.3" + jest-docblock "^26.0.0" + jest-haste-map "^26.6.2" + jest-leak-detector "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" + jest-runtime "^26.6.3" + jest-util "^26.6.2" + jest-worker "^26.6.2" + source-map-support "^0.5.6" + throat "^5.0.0" + +jest-runtime@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-26.6.3.tgz#4f64efbcfac398331b74b4b3c82d27d401b8fa2b" + integrity sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw== + dependencies: + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/globals" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/yargs" "^15.0.0" + chalk "^4.0.0" + cjs-module-lexer "^0.6.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.3" + graceful-fs "^4.2.4" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" + jest-regex-util "^26.0.0" + jest-resolve "^26.6.2" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" + slash "^3.0.0" + strip-bom "^4.0.0" + yargs "^15.4.1" + +jest-serializer@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-26.6.2.tgz#d139aafd46957d3a448f3a6cdabe2919ba0742d1" + integrity sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g== + dependencies: + "@types/node" "*" + graceful-fs "^4.2.4" + +jest-snapshot@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-26.6.2.tgz#f3b0af1acb223316850bd14e1beea9837fb39c84" + integrity sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og== + dependencies: + "@babel/types" "^7.0.0" + "@jest/types" "^26.6.2" + "@types/babel__traverse" "^7.0.4" + "@types/prettier" "^2.0.0" + chalk "^4.0.0" + expect "^26.6.2" + graceful-fs "^4.2.4" + jest-diff "^26.6.2" + jest-get-type "^26.3.0" + jest-haste-map "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" + natural-compare "^1.4.0" + pretty-format "^26.6.2" + semver "^7.3.2" + +jest-util@^26.1.0, jest-util@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz#907535dbe4d5a6cb4c47ac9b926f6af29576cbc1" + integrity sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q== + dependencies: + "@jest/types" "^26.6.2" + "@types/node" "*" + chalk "^4.0.0" + graceful-fs "^4.2.4" + is-ci "^2.0.0" + micromatch "^4.0.2" + +jest-validate@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-26.6.2.tgz#23d380971587150467342911c3d7b4ac57ab20ec" + integrity sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ== + dependencies: + "@jest/types" "^26.6.2" + camelcase "^6.0.0" + chalk "^4.0.0" + jest-get-type "^26.3.0" + leven "^3.1.0" + pretty-format "^26.6.2" + +jest-watcher@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-26.6.2.tgz#a5b683b8f9d68dbcb1d7dae32172d2cca0592975" + integrity sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ== + dependencies: + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + jest-util "^26.6.2" + string-length "^4.0.1" + +jest-when@^3.2.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jest-when/-/jest-when-3.3.1.tgz#04f978b2e522a290b1d91db7ab6ca029a7925513" + integrity sha512-nbQxKeHqfmoSE38TfLVPCgxG+rnsgHSXsdH1wdE9bqHt9US6twHjSXV+fD4ncfsIWNXqhv7zRvN5jn/QYL2UwA== + dependencies: + bunyan "^1.8.12" + expect "^24.8.0" + +jest-worker@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" + integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^7.0.0" + +jest-worker@^27.0.2: + version "27.0.2" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.0.2.tgz#4ebeb56cef48b3e7514552f80d0d80c0129f0b05" + integrity sha512-EoBdilOTTyOgmHXtw/cPc+ZrCA0KJMrkXzkrPGNwLmnvvlN1nj7MPrxpT7m+otSv2e1TLaVffzDnE/LB14zJMg== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +jest@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest/-/jest-26.6.3.tgz#40e8fdbe48f00dfa1f0ce8121ca74b88ac9148ef" + integrity sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q== + dependencies: + "@jest/core" "^26.6.3" + import-local "^3.0.2" + jest-cli "^26.6.3" + +joi@^17.4.0: + version "17.4.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.4.0.tgz#b5c2277c8519e016316e49ababd41a1908d9ef20" + integrity sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.0" + "@sideway/formula" "^3.0.0" + "@sideway/pinpoint" "^2.0.0" + +join-component@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/join-component/-/join-component-1.1.0.tgz#b8417b750661a392bee2c2537c68b2a9d4977cd5" + integrity sha1-uEF7dQZho5K+4sJTfGiyqdSXfNU= + +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-yaml@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" + integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q== + dependencies: + argparse "^2.0.1" + +js-yaml@^3.13.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +jsdom@^16.4.0: + version "16.6.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.6.0.tgz#f79b3786682065492a3da6a60a4695da983805ac" + integrity sha512-Ty1vmF4NHJkolaEmdjtxTfSfkdb8Ywarwf63f+F8/mDD1uLSSWDxDuMiZxiPhwunLrn9LOSVItWj4bLYsLN3Dg== + dependencies: + abab "^2.0.5" + acorn "^8.2.4" + acorn-globals "^6.0.0" + cssom "^0.4.4" + cssstyle "^2.3.0" + data-urls "^2.0.0" + decimal.js "^10.2.1" + domexception "^2.0.1" + escodegen "^2.0.0" + form-data "^3.0.0" + html-encoding-sniffer "^2.0.1" + http-proxy-agent "^4.0.1" + https-proxy-agent "^5.0.0" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.0" + parse5 "6.0.1" + saxes "^5.0.1" + symbol-tree "^3.2.4" + tough-cookie "^4.0.0" + w3c-hr-time "^1.0.2" + w3c-xmlserializer "^2.0.0" + webidl-conversions "^6.1.0" + whatwg-encoding "^1.0.5" + whatwg-mimetype "^2.3.0" + whatwg-url "^8.5.0" + ws "^7.4.5" + xml-name-validator "^3.0.0" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +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-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +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-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +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-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= + +json5@2.x, json5@^2.1.2: + version "2.2.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" + integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA== + dependencies: + minimist "^1.2.5" + +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" + +jsonc-parser@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.0.0.tgz#abdd785701c7e7eaca8a9ec8cf070ca51a745a22" + integrity sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA== + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +jsonpath@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/jsonpath/-/jsonpath-1.1.1.tgz#0ca1ed8fb65bb3309248cc9d5466d12d5b0b9901" + integrity sha512-l6Cg7jRpixfbgoWgkrl77dgEj8RPvND0wMH6TwQmi9Qs4TFfS9u5cUFnbeKTwj5ga5Y3BTGGNI28k117LJ009w== + dependencies: + esprima "1.2.2" + static-eval "2.0.2" + underscore "1.12.1" + +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" + +keytar@^7.7.0: + version "7.7.0" + resolved "https://registry.yarnpkg.com/keytar/-/keytar-7.7.0.tgz#3002b106c01631aa79b1aa9ee0493b94179bbbd2" + integrity sha512-YEY9HWqThQc5q5xbXbRwsZTh2PJ36OSYRjSv3NN2xf5s5dpLTjEZnC2YikR29OaVybf9nQ0dJ/80i40RS97t/A== + dependencies: + node-addon-api "^3.0.0" + prebuild-install "^6.0.0" + +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.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.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" + +lines-and-columns@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" + integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= + +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-runner@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.2.0.tgz#d7022380d66d14c5fb1d496b89864ebcfd478384" + integrity sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw== + +loader-utils@^1.0.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" + integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== + dependencies: + big.js "^5.2.2" + emojis-list "^3.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" + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= + +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + +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.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lodash.toarray@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561" + integrity sha1-JMS/zWsvuji/0FlNsRedjptlZWE= + +lodash.truncate@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" + integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= + +lodash@4.17.21, lodash@4.x, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" + integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== + dependencies: + chalk "^4.0.0" + +log-symbols@^4.0.0, log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +logform@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.2.0.tgz#40f036d19161fc76b68ab50fdc7fe495544492f2" + integrity sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg== + dependencies: + colors "^1.2.1" + fast-safe-stringify "^2.0.4" + fecha "^4.2.0" + ms "^2.1.1" + triple-beam "^1.3.0" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +lru-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM= + dependencies: + es5-ext "~0.10.2" + +macos-release@^2.2.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.5.0.tgz#067c2c88b5f3fb3c56a375b2ec93826220fa1ff2" + integrity sha512-EIgv+QZ9r+814gjJj0Bt5vSLJLzswGmSUbUpbi9AIr/fsN2IWFBl2NucV9PAiek+U1STK468tEkxmVYUtuAN3g== + +magic-string@0.25.7: + version "0.25.7" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" + integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA== + dependencies: + sourcemap-codec "^1.4.4" + +make-dir@^3.0.0, make-dir@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + +make-error@1.x, make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +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-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" + +md5@^2.1.0, md5@^2.2.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" + integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g== + dependencies: + charenc "0.0.2" + crypt "0.0.2" + is-buffer "~1.1.6" + +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= + +memfs@^3.1.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.2.2.tgz#5de461389d596e3f23d48bb7c2afb6161f4df40e" + integrity sha512-RE0CwmIM3CEvpcdK3rZ19BC4E6hv9kADkMN5rPduRak58cNArWLi/9jFLsa4rhsjfVxMP3v0jO7FHXq7SvFY5Q== + dependencies: + fs-monkey "1.0.3" + +memoizee@^0.4.14: + version "0.4.15" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.15.tgz#e6f3d2da863f318d02225391829a6c5956555b72" + integrity sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ== + dependencies: + d "^1.0.1" + es5-ext "^0.10.53" + es6-weak-map "^2.0.3" + event-emitter "^0.3.5" + is-promise "^2.2.2" + lru-queue "^0.1.0" + next-tick "^1.1.0" + timers-ext "^0.1.7" + +memory-fs@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.5.0.tgz#324c01288b88652966d161db77838720845a8e3c" + integrity sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA== + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +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@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +methods@^1.1.1, methods@^1.1.2, 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@^3.1.10, micromatch@^3.1.4: + 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" + +micromatch@^4.0.0, micromatch@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9" + integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg== + dependencies: + braces "^3.0.1" + picomatch "^2.2.3" + +mime-db@1.48.0: + version "1.48.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" + integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== + +mime-db@1.49.0: + version "1.49.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed" + integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA== + +mime-types@^2.1.12, mime-types@~2.1.19: + version "2.1.32" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5" + integrity sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A== + dependencies: + mime-db "1.49.0" + +mime-types@^2.1.27, mime-types@~2.1.24: + version "2.1.31" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.31.tgz#a00d76b74317c61f9c2db2218b8e9f8e9c5c9e6b" + integrity sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg== + dependencies: + mime-db "1.48.0" + +mime@1.6.0, 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== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +mimic-response@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" + integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== + +"minimatch@2 || 3", minimatch@3.0.4, 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@1.2.5, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +minipass@^2.6.0, minipass@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" + integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== + dependencies: + safe-buffer "^5.1.2" + yallist "^3.0.0" + +minizlib@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" + integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== + dependencies: + minipass "^2.9.0" + +mixin-deep@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + +mkdirp@1.x, mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + +"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.5, mkdirp@~0.5.1: + version "0.5.5" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== + dependencies: + minimist "^1.2.5" + +mocha-junit-reporter@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mocha-junit-reporter/-/mocha-junit-reporter-2.0.0.tgz#3bf990fce7a42c0d2b718f188553a25d9f24b9a2" + integrity sha512-20HoWh2HEfhqmigfXOKUhZQyX23JImskc37ZOhIjBKoBEsb+4cAFRJpAVhFpnvsztLklW/gFVzsrobjLwmX4lA== + dependencies: + debug "^2.2.0" + md5 "^2.1.0" + mkdirp "~0.5.1" + strip-ansi "^4.0.0" + xml "^1.0.0" + +mocha-multi-reporters@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/mocha-multi-reporters/-/mocha-multi-reporters-1.5.1.tgz#c73486bed5519e1d59c9ce39ac7a9792600e5676" + integrity sha512-Yb4QJOaGLIcmB0VY7Wif5AjvLMUFAdV57D2TWEva1Y0kU/3LjKpeRVmlMIfuO1SVbauve459kgtIizADqxMWPg== + dependencies: + debug "^4.1.1" + lodash "^4.17.15" + +mocha@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.4.0.tgz#677be88bf15980a3cae03a73e10a0fc3997f0cff" + integrity sha512-hJaO0mwDXmZS4ghXsvPVriOhsxQ7ofcpQdm8dE+jISUOKopitvnXFQmpRR7jd2K6VBG6E26gU3IAbXXGIbu4sQ== + dependencies: + "@ungap/promise-all-settled" "1.1.2" + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.5.1" + debug "4.3.1" + diff "5.0.0" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.1.6" + growl "1.10.5" + he "1.2.0" + js-yaml "4.0.0" + log-symbols "4.0.0" + minimatch "3.0.4" + ms "2.1.3" + nanoid "3.1.20" + serialize-javascript "5.0.1" + strip-json-comments "3.1.1" + supports-color "8.1.1" + which "2.0.2" + wide-align "1.1.3" + workerpool "6.1.0" + yargs "16.2.0" + yargs-parser "20.2.4" + yargs-unparser "2.0.0" + +moment@^2.11.2, moment@^2.19.3: + version "2.29.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + +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== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3, ms@^2.0.0, ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +multer@1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a" + integrity sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg== + dependencies: + append-field "^1.0.0" + busboy "^0.2.11" + concat-stream "^1.5.2" + mkdirp "^0.5.1" + object-assign "^4.1.1" + on-finished "^2.3.0" + type-is "^1.6.4" + xtend "^4.0.0" + +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + +mv@~2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/mv/-/mv-2.1.1.tgz#ae6ce0d6f6d5e0a4f7d893798d03c1ea9559b6a2" + integrity sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI= + dependencies: + mkdirp "~0.5.1" + ncp "~2.0.0" + rimraf "~2.4.0" + +mz@^2.4.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" + integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q== + dependencies: + any-promise "^1.0.0" + object-assign "^4.0.1" + thenify-all "^1.0.0" + +nan@^2.14.0: + version "2.14.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19" + integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== + +nanoid@3.1.20: + version "3.1.20" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788" + integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw== + +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" + +napi-build-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" + integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== + +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= + +ncp@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M= + +needle@^2.2.1: + version "2.9.1" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.9.1.tgz#22d1dffbe3490c2b83e301f7709b6736cd8f2684" + integrity sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ== + dependencies: + debug "^3.2.6" + iconv-lite "^0.4.4" + sax "^1.2.4" + +negotiator@0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" + integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +nest-router@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/nest-router/-/nest-router-1.0.9.tgz#cbe814dadf90b765e0a5b77c562479d5d523a485" + integrity sha512-ZyRdSVs9GczI+39B7tNXsxfBXQOYnEF6l/q2aLYG8wSEvRHRDXAlzZ1SIosDibM02pLahGkDNLFC+nZ8uzJGDQ== + +nest-winston@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/nest-winston/-/nest-winston-1.5.0.tgz#2a1d8c5e7f7abc933f10cd80e05548d03b63684b" + integrity sha512-hjJOZrzLbHe5BsN00OIi9Y7lT3ZMT4HX/1SbA5oKS0SkbjvvwxavpUz3N9itL6Oznh0B7JVjUXZLLK8aGULL0w== + dependencies: + cli-color "^2.0.0" + fast-safe-stringify "^2.0.7" + +next-tick@1, next-tick@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" + integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== + +next-tick@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" + integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= + +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== + +node-abi@^2.21.0: + version "2.30.1" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.30.1.tgz#c437d4b1fe0e285aaf290d45b45d4d7afedac4cf" + integrity sha512-/2D0wOQPgaUWzVSVgRMx+trKJRC2UG4SUc4oCJoXx9Uxjtp0Vy3/kt7zcbxHF8+Z/pK3UloLWzBISg72brfy1w== + dependencies: + semver "^5.4.1" + +node-addon-api@^3.0.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" + integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== + +node-emoji@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da" + integrity sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw== + dependencies: + lodash.toarray "^4.4.0" + +node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + +node-gyp@3.x: + version "3.8.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" + integrity sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA== + dependencies: + fstream "^1.0.0" + glob "^7.0.3" + graceful-fs "^4.1.2" + mkdirp "^0.5.0" + nopt "2 || 3" + npmlog "0 || 1 || 2 || 3 || 4" + osenv "0" + request "^2.87.0" + rimraf "2" + semver "~5.3.0" + tar "^2.0.0" + which "1" + +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-modules-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" + integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= + +node-notifier@^8.0.0: + version "8.0.2" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-8.0.2.tgz#f3167a38ef0d2c8a866a83e318c1ba0efeb702c5" + integrity sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg== + dependencies: + growly "^1.3.0" + is-wsl "^2.2.0" + semver "^7.3.2" + shellwords "^0.1.1" + uuid "^8.3.0" + which "^2.0.2" + +node-pre-gyp@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz#db1f33215272f692cd38f03238e3e9b47c5dd054" + integrity sha512-TwWAOZb0j7e9eGaf9esRx3ZcLaE5tQ2lvYy1pb5IAaG1a2e2Kv5Lms1Y4hpj+ciXJRofIxxlt5haeQ/2ANeE0Q== + 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-preload@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/node-preload/-/node-preload-0.2.1.tgz#c03043bb327f417a18fee7ab7ee57b408a144301" + integrity sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ== + dependencies: + process-on-spawn "^1.0.0" + +node-releases@^1.1.71: + version "1.1.73" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.73.tgz#dd4e81ddd5277ff846b80b52bb40c49edf7a7b20" + integrity sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg== + +node-version-compare@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/node-version-compare/-/node-version-compare-1.0.3.tgz#ca6d2005e67822fb4dfa259e08f1f6cfaabe2e81" + integrity sha512-unO5GpBAh5YqeGULMLpmDT94oanSDMwtZB8KHTKCH/qrGv8bHN0mlDj9xQDAicCYXv2OLnzdi67lidCrcVotVw== + +"nopt@2 || 3": + version "3.0.6" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" + integrity sha1-xkZdvwirzU2zWTF/eaxopkayj/k= + dependencies: + abbrev "1" + +nopt@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48" + integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg== + dependencies: + abbrev "1" + osenv "^0.1.4" + +normalize-package-data@^2.3.2, normalize-package-data@^2.5.0: + 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.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, 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== + +npm-bundled@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1" + integrity sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ== + dependencies: + npm-normalize-package-bin "^1.0.1" + +npm-normalize-package-bin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" + integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== + +npm-packlist@^1.1.6: + version "1.4.8" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e" + integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== + dependencies: + ignore-walk "^3.0.1" + npm-bundled "^1.0.1" + npm-normalize-package-bin "^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" + +npm-run-path@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.1, 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" + +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.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7" + integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ== + +nyc@^15.1.0: + version "15.1.0" + resolved "https://registry.yarnpkg.com/nyc/-/nyc-15.1.0.tgz#1335dae12ddc87b6e249d5a1994ca4bdaea75f02" + integrity sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A== + dependencies: + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + caching-transform "^4.0.0" + convert-source-map "^1.7.0" + decamelize "^1.2.0" + find-cache-dir "^3.2.0" + find-up "^4.1.0" + foreground-child "^2.0.0" + get-package-type "^0.1.0" + glob "^7.1.6" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-hook "^3.0.0" + istanbul-lib-instrument "^4.0.0" + istanbul-lib-processinfo "^2.0.2" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.0.2" + make-dir "^3.0.0" + node-preload "^0.2.1" + p-map "^3.0.0" + process-on-spawn "^1.0.0" + resolve-from "^5.0.0" + rimraf "^3.0.0" + signal-exit "^3.0.2" + spawn-wrap "^2.0.0" + test-exclude "^6.0.0" + yargs "^15.0.2" + +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, 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-diff@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/object-diff/-/object-diff-0.0.4.tgz#d883b0444fe8fd6e04e595d7bb665682c916047f" + integrity sha1-2IOwRE/o/W4E5ZXXu2ZWgskWBH8= + +object-hash@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.1.1.tgz#9447d0279b4fcf80cff3259bf66a1dc73afabe09" + integrity sha512-VOJmgmS+7wvXf8CjbQmimtCnEx3IAoLxI3fp2fbWehxrWBcAQFbk+vcwb6vzR0VZv/eNCJ/27j151ZTwqW/JeQ== + +object-hash@^2.0.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" + integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== + +object-inspect@^1.10.3, object-inspect@^1.9.0: + version "1.10.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.10.3.tgz#c2aa7d2d09f50c99375704f7a0adf24c5782d369" + integrity sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw== + +object-keys@^1.0.12, object-keys@^1.1.1: + 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.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" + integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + has-symbols "^1.0.1" + object-keys "^1.1.1" + +object.entries@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.4.tgz#43ccf9a50bc5fd5b649d45ab1a579f24e088cafd" + integrity sha512-h4LWKWE+wKQGhtMjZEBud7uLGhqyLwj8fpHOarZhD2uY3C9cRtk57VQ89ke3moByLXMedqs3XCHzyb4AmA2DjA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.18.2" + +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.1.3: + version "1.1.4" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.4.tgz#0d273762833e816b693a637d30073e7051535b30" + integrity sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + es-abstract "^1.18.2" + +on-finished@^2.3.0, 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" + +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" + +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +optional@0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/optional/-/optional-0.1.4.tgz#cdb1a9bedc737d2025f690ceeb50e049444fd5b3" + integrity sha512-gtvrrCfkE08wKcgXaVwQVgwEQ8vel2dc5DDBn9RLQZ3YtmtkBss6A2HY6BnJH4N/4Ku97Ri/SF8sNWE2225WJw== + +optionator@^0.8.1: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + +optionator@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" + integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.3" + +ora@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/ora/-/ora-5.3.0.tgz#fb832899d3a1372fe71c8b2c534bbfe74961bb6f" + integrity sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g== + dependencies: + bl "^4.0.3" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + log-symbols "^4.0.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + +ora@5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.0.tgz#42eda4855835b9cd14d33864c97a3c95a3f56bf4" + integrity sha512-1StwyXQGoU6gdjYkyVcqOLnVlbKj+6yPNNOxJVgpt9t4eksKjiriiHuxktLYkgllwk+D6MbC4ihH84L1udRXPg== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + +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-name@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/os-name/-/os-name-4.0.0.tgz#6c05c09c41c15848ea74658d12c9606f0f286599" + integrity sha512-caABzDdJMbtykt7GmSogEat3faTKQhmZf0BS5l/pZGmP0vPWQjXWqOhbLyK+b6j2/DQPmEvYdzLXJXXLJNVDNg== + dependencies: + macos-release "^2.2.0" + windows-release "^4.0.0" + +os-tmpdir@^1.0.0, 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, 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-each-series@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a" + integrity sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA== + +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-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, p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2, p-limit@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.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-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-map@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== + +p-map@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-3.0.0.tgz#d704d9af8a2ba684e2600d9a215983d4141a979d" + integrity sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ== + dependencies: + aggregate-error "^3.0.0" + +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== + +package-hash@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/package-hash/-/package-hash-4.0.0.tgz#3537f654665ec3cc38827387fc904c163c54f506" + integrity sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ== + dependencies: + graceful-fs "^4.1.15" + hasha "^5.0.0" + lodash.flattendeep "^4.4.0" + release-zalgo "^1.0.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" + +parent-require@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/parent-require/-/parent-require-1.0.0.tgz#746a167638083a860b0eef6732cb27ed46c32977" + integrity sha1-dGoWdjgIOoYLDu9nMssn7UbDKXc= + +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" + +parse-json@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse5-htmlparser2-tree-adapter@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" + integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== + dependencies: + parse5 "^6.0.1" + +parse5@6.0.1, parse5@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== + +parse5@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" + integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +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-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-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + 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-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-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +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@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.2.0.tgz#fa7877ecbc495c601907562222453c43cc204a5f" + integrity sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA== + +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" + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +pathval@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" + integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== + +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= + +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3: + version "2.3.0" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" + integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== + +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= + +pirates@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" + integrity sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA== + dependencies: + node-modules-regexp "^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@^4.1.0, pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.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@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + +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= + +prebuild-install@^6.0.0: + version "6.1.4" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.4.tgz#ae3c0142ad611d58570b89af4986088a4937e00f" + integrity sha512-Z4vpywnK1lBg+zdPCVCsKq0xO66eEV9rWo2zrROGGiRS4JtueBOdlB1FnY8lcy7JsUud/Q3ijUxyWN26Ika0vQ== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^2.21.0" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +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= + +pretty-format@^24.9.0: + version "24.9.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.9.0.tgz#12fac31b37019a4eea3c11aa9a959eb7628aa7c9" + integrity sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA== + dependencies: + "@jest/types" "^24.9.0" + ansi-regex "^4.0.0" + ansi-styles "^3.2.0" + react-is "^16.8.4" + +pretty-format@^26.0.0, pretty-format@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" + integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== + dependencies: + "@jest/types" "^26.6.2" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^17.0.1" + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +process-on-spawn@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/process-on-spawn/-/process-on-spawn-1.0.0.tgz#95b05a23073d30a17acfdc92a440efd2baefdc93" + integrity sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg== + dependencies: + fromentries "^1.2.0" + +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== + +prompts@^2.0.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.1.tgz#befd3b1195ba052f9fd2fde8a486c4e82ee77f61" + integrity sha512-EQyfIuO2hPDsX1L/blblV+H7I0knhgAd82cVneCwcdND9B8AuCDuRcBH6yIcG4dFzlOUqbazQqwGjx5xmsNLuQ== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + +proxy-addr@~2.0.5: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +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= + +psl@^1.1.28, psl@^1.1.33: + version "1.8.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" + integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== + +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" + +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== + +qs@6.7.0: + version "6.7.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" + integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== + +qs@^6.5.1: + version "6.10.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" + integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg== + dependencies: + side-channel "^1.0.4" + +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== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +randombytes@^2.1.0: + 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" + +range-parser@~1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332" + integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q== + dependencies: + bytes "3.1.0" + http-errors "1.7.2" + iconv-lite "0.4.24" + 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-is@^16.8.4: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +read-pkg-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07" + integrity sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc= + dependencies: + find-up "^2.0.0" + read-pkg "^3.0.0" + +read-pkg-up@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" + integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + dependencies: + find-up "^4.1.0" + read-pkg "^5.2.0" + type-fest "^0.8.1" + +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" + +read-pkg@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-4.0.1.tgz#963625378f3e1c4d48c85872b5a6ec7d5d093237" + integrity sha1-ljYlN48+HE1IyFhytabsfV0JMjc= + dependencies: + normalize-package-data "^2.3.2" + parse-json "^4.0.0" + pify "^3.0.0" + +read-pkg@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + +readable-stream@1.1.x: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.0.1, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@^2.3.7: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + 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.1.1, readable-stream@^3.4.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" + integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== + dependencies: + picomatch "^2.2.1" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +readline-sync@^1.4.9: + version "1.4.10" + resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b" + integrity sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw== + +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q= + dependencies: + resolve "^1.1.6" + +redis-commands@1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89" + integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + +reflect-metadata@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" + integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + +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" + +regexpp@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" + integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== + +release-zalgo@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/release-zalgo/-/release-zalgo-1.0.0.tgz#09700b7e5074329739330e535c5a90fb67851730" + integrity sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA= + dependencies: + es6-error "^4.0.1" + +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= + +remove-trailing-slash@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/remove-trailing-slash/-/remove-trailing-slash-0.1.1.tgz#be2285a59f39c74d1bce4f825950061915e3780d" + integrity sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA== + +repeat-element@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9" + integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ== + +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= + +request@^2.87.0: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + 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.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +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.2: + 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@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +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-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +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.6, resolve@^1.10.0, resolve@^1.13.1, resolve@^1.18.1, resolve@^1.20.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.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== + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@2, rimraf@^2.6.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + +rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +rimraf@~2.4.0: + version "2.4.5" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" + integrity sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto= + dependencies: + glob "^6.0.1" + +rsvp@^4.8.4: + version "4.8.5" + resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" + integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== + +run-async@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +rxjs@6.6.3: + version "6.6.3" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" + integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== + dependencies: + tslib "^1.9.0" + +rxjs@^6.5.2, rxjs@^6.6.0, rxjs@^6.6.7: + version "6.6.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" + integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== + dependencies: + tslib "^1.9.0" + +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-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-json-stringify@~1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz#356e44bc98f1f93ce45df14bcd7c01cda86e0afd" + integrity sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg== + +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@^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" + +sax@>=0.6.0, 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== + +saxes@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" + integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== + dependencies: + xmlchars "^2.2.0" + +schema-utils@2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" + integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A== + dependencies: + "@types/json-schema" "^7.0.4" + ajv "^6.12.2" + ajv-keywords "^3.4.1" + +schema-utils@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef" + integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA== + dependencies: + "@types/json-schema" "^7.0.6" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@7.x, semver@^7.2.1, semver@^7.3.2, semver@^7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" + +semver@^6.0.0, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +semver@~5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" + integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= + +send@0.17.1: + version "0.17.1" + resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" + integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg== + 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.7.2" + mime "1.6.0" + ms "2.1.1" + on-finished "~2.3.0" + range-parser "~1.2.1" + statuses "~1.5.0" + +serialize-javascript@5.0.1, serialize-javascript@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4" + integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA== + dependencies: + randombytes "^2.1.0" + +serve-static@1.14.1: + version "1.14.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" + integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== + dependencies: + encodeurl "~1.0.2" + escape-html "~1.0.3" + parseurl "~1.3.3" + send "0.17.1" + +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@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setprototypeof@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" + integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== + +sha.js@^2.4.11: + 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" + +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-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.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= + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +shelljs@0.8.4: + version "0.8.4" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2" + integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ== + dependencies: + glob "^7.0.0" + interpret "^1.0.0" + rechoir "^0.6.2" + +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== + +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== + dependencies: + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" + integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== + +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" + integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== + dependencies: + decompress-response "^4.2.0" + once "^1.3.1" + simple-concat "^1.0.0" + +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@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +slice-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" + integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.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" + +source-list-map@^2.0.1: + 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.3" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" + integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@^0.5.17, source-map-support@^0.5.19, source-map-support@^0.5.6, source-map-support@~0.5.19: + version "0.5.19" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" + integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-url@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56" + integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== + +source-map@0.7.3, source-map@^0.7.3, source-map@~0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== + +source-map@^0.5.0, source-map@^0.5.6: + 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.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sourcemap-codec@^1.4.4: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + +spawn-command@^0.0.2-1: + version "0.0.2-1" + resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" + integrity sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A= + +spawn-wrap@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-2.0.0.tgz#103685b8b8f9b79771318827aa78650a610d457e" + integrity sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg== + dependencies: + foreground-child "^2.0.0" + is-windows "^1.0.2" + make-dir "^3.0.0" + rimraf "^3.0.0" + signal-exit "^3.0.2" + which "^2.0.1" + +spdx-correct@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" + integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" + integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== + +spdx-expression-parse@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.9" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.9.tgz#8a595135def9592bda69709474f1cbeea7c2467f" + integrity sha512-Ki212dKK4ogX+xDo4CtOZBVIwhsKBEfsEEcwmJfLQzirgc2jIWdzg40Unxz/HzEUqM1WFzVlQSMF9kZZ2HboLQ== + +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.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673" + integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug== + +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= + +sqlite3@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-5.0.2.tgz#00924adcc001c17686e0a6643b6cbbc2d3965083" + integrity sha512-1SdTNo+BVU211Xj1csWa8lV6KM0CtucDwRyA0VHl91wEH1Mgh7RxUpI4rVvG7OhHrzCSGaVyW5g8vKvlrk9DJA== + dependencies: + node-addon-api "^3.0.0" + node-pre-gyp "^0.11.0" + optionalDependencies: + node-gyp "3.x" + +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" + +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= + +stack-utils@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.5.tgz#a19b0b01947e0029c8e451d5d61a498f5bb1471b" + integrity sha512-KZiTzuV3CnSnSvgMRrARVCj+Ht7rMbauGDK0LdVFRGyenwdylpajAp4Q0i6SX8rEmbTpMMf6ryq2gb8pPq2WgQ== + dependencies: + escape-string-regexp "^2.0.0" + +stack-utils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.3.tgz#cd5f030126ff116b78ccb3c027fe302713b61277" + integrity sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw== + dependencies: + escape-string-regexp "^2.0.0" + +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + +static-eval@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.2.tgz#2d1759306b1befa688938454c546b7871f806a42" + integrity sha512-N/D219Hcr2bPjLxPiV+TQE++Tsmrady7TqAJugLy7Xk1EumfDWS/f5dtBbkRCGE7wKKXuYockQoj8Rm2/pVKyg== + dependencies: + escodegen "^1.8.1" + +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.5.0 < 2", statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= + +streamsearch@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" + integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= + +string-length@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" + integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== + dependencies: + char-regex "^1.0.2" + strip-ansi "^6.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.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, string-width@^3.1.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-width@^4.1.0, string-width@^4.2.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" + integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +string.prototype.trimend@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" + integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +string.prototype.trimstart@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" + integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + +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" + +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, strip-ansi@^5.2.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-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.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@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + +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-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-json-comments@3.1.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +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= + +superagent@^3.8.3: + 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" + +supertest@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-4.0.2.tgz#c2234dbdd6dc79b6f15b99c8d6577b90e4ce3f36" + integrity sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ== + dependencies: + methods "^1.1.2" + superagent "^3.8.3" + +supports-color@8.1.1, supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +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@^5.3.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" + +supports-color@^7.0.0, supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-hyperlinks@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz#4f77b42488765891774b70c79babd87f9bd594bb" + integrity sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ== + dependencies: + has-flag "^4.0.0" + supports-color "^7.0.0" + +swagger-ui-dist@^3.18.1: + version "3.50.0" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-3.50.0.tgz#a06ace5820874ff9b337afb91bb08e76fcd12d57" + integrity sha512-BklniOBPlvZ6M9oGkhUwOf5HvxhkHBIycXN3ndju8WlLmi1xfMSdOA2AR6pNswlwURzsZUe1rh80aUyjnpD+Zw== + +swagger-ui-express@^4.1.4: + version "4.1.6" + resolved "https://registry.yarnpkg.com/swagger-ui-express/-/swagger-ui-express-4.1.6.tgz#682294af3d5c70f74a1fa4d6a9b503a9ee55ea82" + integrity sha512-Xs2BGGudvDBtL7RXcYtNvHsFtP1DBFPMJFRxHe5ez/VG/rzVOEjazJOOSc/kSCyxreCTKfJrII6MJlL9a6t8vw== + dependencies: + swagger-ui-dist "^3.18.1" + +symbol-observable@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-3.0.0.tgz#eea8f6478c651018e059044268375c408c15c533" + integrity sha512-6tDOXSHiVjuCaasQSWTmHUWn4PuG7qa3+1WT031yTc/swT7+rLiw3GOrFxaH1E3lLP09dH3bVuVDf2gK5rxG3Q== + +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + +table@^6.0.9: + version "6.7.1" + resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" + integrity sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg== + dependencies: + ajv "^8.0.1" + lodash.clonedeep "^4.5.0" + lodash.truncate "^4.4.2" + slice-ansi "^4.0.0" + string-width "^4.2.0" + strip-ansi "^6.0.0" + +tapable@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" + integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== + +tapable@^2.1.1, tapable@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.0.tgz#5c373d281d9c672848213d0e037d1c4165ab426b" + integrity sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw== + +tar-fs@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar@^2.0.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40" + integrity sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA== + dependencies: + block-stream "*" + fstream "^1.0.12" + inherits "2" + +tar@^4: + version "4.4.19" + resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.19.tgz#2e4d7263df26f2b914dee10c825ab132123742f3" + integrity sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA== + dependencies: + chownr "^1.1.4" + fs-minipass "^1.2.7" + minipass "^2.9.0" + minizlib "^1.3.3" + mkdirp "^0.5.5" + safe-buffer "^5.2.1" + yallist "^3.1.1" + +terminal-link@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994" + integrity sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ== + dependencies: + ansi-escapes "^4.2.1" + supports-hyperlinks "^2.0.0" + +terser-webpack-plugin@^5.1.1: + version "5.1.3" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.1.3.tgz#30033e955ca28b55664f1e4b30a1347e61aa23af" + integrity sha512-cxGbMqr6+A2hrIB5ehFIF+F/iST5ZOxvOmy9zih9ySbP1C2oEWQSOUS+2SNBTjzx5xLKO4xnod9eywdfq1Nb9A== + dependencies: + jest-worker "^27.0.2" + p-limit "^3.1.0" + schema-utils "^3.0.0" + serialize-javascript "^5.0.1" + source-map "^0.6.1" + terser "^5.7.0" + +terser@^5.7.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.7.0.tgz#a761eeec206bc87b605ab13029876ead938ae693" + integrity sha512-HP5/9hp2UaZt5fYkuhNBR8YyRcT8juw8+uFbAme53iN9hblvKnLUTKkmwJG6ocWpIKf8UK4DoeWG4ty0J6S6/g== + dependencies: + commander "^2.20.0" + source-map "~0.7.2" + source-map-support "~0.5.19" + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + +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= + +thenify-all@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" + integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY= + dependencies: + thenify ">= 3.1.0 < 4" + +"thenify@>= 3.1.0 < 4": + version "3.3.1" + resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f" + integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw== + dependencies: + any-promise "^1.0.0" + +throat@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" + integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== + +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= + +timers-ext@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.7.tgz#6f57ad8578e07a3fb9f91d9387d65647555e25c6" + integrity sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ== + dependencies: + es5-ext "~0.10.46" + next-tick "1" + +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-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-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-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +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" + +toidentifier@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" + integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== + +tough-cookie@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" + integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.1.2" + +tough-cookie@~2.5.0: + 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" + +tr46@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" + integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== + dependencies: + punycode "^2.1.1" + +tree-kill@1.2.2, tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + +triple-beam@^1.2.0, triple-beam@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" + integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== + +ts-jest@^26.1.0: + version "26.5.6" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.5.6.tgz#c32e0746425274e1dfe333f43cd3c800e014ec35" + integrity sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA== + dependencies: + bs-logger "0.x" + buffer-from "1.x" + fast-json-stable-stringify "2.x" + jest-util "^26.1.0" + json5 "2.x" + lodash "4.x" + make-error "1.x" + mkdirp "1.x" + semver "7.x" + yargs-parser "20.x" + +ts-loader@^6.2.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-6.2.2.tgz#dffa3879b01a1a1e0a4b85e2b8421dc0dfff1c58" + integrity sha512-HDo5kXZCBml3EUPcc7RlZOV/JGlLHwppTLEHb3SHnr5V7NXD4klMEkrhJe5wgRbaWsSXi+Y1SIBN/K9B6zWGWQ== + dependencies: + chalk "^2.3.0" + enhanced-resolve "^4.0.0" + loader-utils "^1.0.2" + micromatch "^4.0.0" + semver "^6.0.0" + +ts-mocha@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/ts-mocha/-/ts-mocha-8.0.0.tgz#962d0fa12eeb6468aa1a6b594bb3bbc818da3ef0" + integrity sha512-Kou1yxTlubLnD5C3unlCVO7nh0HERTezjoVhVw/M5S1SqoUec0WgllQvPk3vzPMc6by8m6xD1uR1yRf8lnVUbA== + dependencies: + ts-node "7.0.1" + optionalDependencies: + tsconfig-paths "^3.5.0" + +ts-node@7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-7.0.1.tgz#9562dc2d1e6d248d24bc55f773e3f614337d9baf" + integrity sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw== + dependencies: + arrify "^1.0.0" + buffer-from "^1.1.0" + diff "^3.1.0" + make-error "^1.1.1" + minimist "^1.2.0" + mkdirp "^0.5.1" + source-map-support "^0.5.6" + yn "^2.0.0" + +ts-node@^9.1.1: + version "9.1.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-9.1.1.tgz#51a9a450a3e959401bda5f004a72d54b936d376d" + integrity sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg== + dependencies: + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + source-map-support "^0.5.17" + yn "3.1.1" + +tsconfig-paths-webpack-plugin@3.5.1, tsconfig-paths-webpack-plugin@^3.3.0: + version "3.5.1" + resolved "https://registry.yarnpkg.com/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-3.5.1.tgz#e4dbf492a20dca9caab60086ddacb703afc2b726" + integrity sha512-n5CMlUUj+N5pjBhBACLq4jdr9cPTitySCjIosoQm0zwK99gmrcTGAfY9CwxRFT9+9OleNWXPRUcxsKP4AYExxQ== + dependencies: + chalk "^4.1.0" + enhanced-resolve "^5.7.0" + tsconfig-paths "^3.9.0" + +tsconfig-paths@3.9.0, tsconfig-paths@^3.5.0, tsconfig-paths@^3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b" + integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.1" + minimist "^1.2.0" + strip-bom "^3.0.0" + +tslib@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" + integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== + +tslib@>=1.9.0, tslib@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" + integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== + +tslib@^1.8.1, tslib@^1.9.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +tsutils@^3.21.0: + version "3.21.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== + dependencies: + tslib "^1.8.1" + +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.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +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-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== + +type-fest@^0.8.0, type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + +type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +type@^1.0.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0" + integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== + +type@^2.0.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/type/-/type-2.5.0.tgz#0a2e78c2e77907b252abe5f298c1b01c63f0db3d" + integrity sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw== + +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + +typeorm@^0.2.29: + version "0.2.34" + resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.2.34.tgz#637b3cec2de54ee7f423012b813a2022c0aacc8b" + integrity sha512-FZAeEGGdSGq7uTH3FWRQq67JjKu0mgANsSZ04j3kvDYNgy9KwBl/6RFgMVgiSgjf7Rqd7NrhC2KxVT7I80qf7w== + dependencies: + "@sqltools/formatter" "^1.2.2" + app-root-path "^3.0.0" + buffer "^6.0.3" + chalk "^4.1.0" + cli-highlight "^2.1.10" + debug "^4.3.1" + dotenv "^8.2.0" + glob "^7.1.6" + js-yaml "^4.0.0" + mkdirp "^1.0.4" + reflect-metadata "^0.1.13" + sha.js "^2.4.11" + tslib "^2.1.0" + xml2js "^0.4.23" + yargonaut "^1.1.4" + yargs "^16.2.0" + zen-observable-ts "^1.0.0" + +typescript@4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3" + integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== + +typescript@^4.0.5: + version "4.3.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805" + integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw== + +unbox-primitive@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" + integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw== + dependencies: + function-bind "^1.1.1" + has-bigints "^1.0.1" + has-symbols "^1.0.2" + which-boxed-primitive "^1.0.2" + +underscore@1.12.1: + version "1.12.1" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e" + integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== + +union-value@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^2.0.1" + +universalify@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + +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= + +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" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + 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= + +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= + +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@8.3.1: + version "8.3.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.1.tgz#2ba2e6ca000da60fce5a196954ab241131e05a31" + integrity sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg== + +uuid@8.3.2, uuid@^8.3.0, uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + +uuid@^3.2.1, uuid@^3.3.2, uuid@^3.3.3: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + +v8-compile-cache@^2.0.3: + version "2.3.0" + resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" + integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== + +v8-to-istanbul@^7.0.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-7.1.2.tgz#30898d1a7fa0c84d225a2c1434fb958f290883c1" + integrity sha512-TxNb7YEUwkLXCQYeudi6lgQ/SZrzNO4kMdlqVxaZPUIUjCv6iSSypUQX70kNBSERpQ8fk48+d61FXk+tgqcWow== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^1.6.0" + source-map "^0.7.3" + +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" + +validator@13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.0.0.tgz#0fb6c6bb5218ea23d368a8347e6d0f5a70e3bcab" + integrity sha512-anYx5fURbgF04lQV18nEQWZ/3wHGnxiKdG4aL8J+jEDsm98n/sU/bey+tYk6tnGJzm7ioh5FoqrAiQ6m03IgaA== + +vary@^1, vary@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + +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" + +w3c-hr-time@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" + integrity sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ== + dependencies: + browser-process-hrtime "^1.0.0" + +w3c-xmlserializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a" + integrity sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA== + dependencies: + xml-name-validator "^3.0.0" + +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" + +watchpack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.2.0.tgz#47d78f5415fe550ecd740f99fe2882323a58b1ce" + integrity sha512-up4YAn/XHgZHIxFBVCdlMiWDj6WaLKpwVeGQk2I5thdYxF/KmF0aaz6TfJZ/hfl1h/XlcDr7k1KH7ThDagpFaA== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g= + dependencies: + defaults "^1.0.3" + +webidl-conversions@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" + integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== + +webidl-conversions@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" + integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== + +webpack-node-externals@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/webpack-node-externals/-/webpack-node-externals-2.5.2.tgz#178e017a24fec6015bc9e672c77958a6afac861d" + integrity sha512-aHdl/y2N7PW2Sx7K+r3AxpJO+aDMcYzMQd60Qxefq3+EwhewSbTBqNumOsCE1JsCUNoyfGj5465N0sSf6hc/5w== + +webpack-sources@^2.1.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-2.3.0.tgz#9ed2de69b25143a4c18847586ad9eccb19278cfa" + integrity sha512-WyOdtwSvOML1kbgtXbTDnEW0jkJ7hZr/bDByIwszhWd/4XX1A3XMkrbFMsuH4+/MfLlZCUzlAdg4r7jaGKEIgQ== + dependencies: + source-list-map "^2.0.1" + source-map "^0.6.1" + +webpack@5.28.0: + version "5.28.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.28.0.tgz#0de8bcd706186b26da09d4d1e8cbd3e4025a7c2f" + integrity sha512-1xllYVmA4dIvRjHzwELgW4KjIU1fW4PEuEnjsylz7k7H5HgPOctIq7W1jrt3sKH9yG5d72//XWzsHhfoWvsQVg== + dependencies: + "@types/eslint-scope" "^3.7.0" + "@types/estree" "^0.0.46" + "@webassemblyjs/ast" "1.11.0" + "@webassemblyjs/wasm-edit" "1.11.0" + "@webassemblyjs/wasm-parser" "1.11.0" + acorn "^8.0.4" + browserslist "^4.14.5" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.7.0" + es-module-lexer "^0.4.0" + eslint-scope "^5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.4" + json-parse-better-errors "^1.0.2" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^3.0.0" + tapable "^2.1.1" + terser-webpack-plugin "^5.1.1" + watchpack "^2.0.0" + webpack-sources "^2.1.1" + +whatwg-encoding@^1.0.5: + 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-mimetype@^2.3.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@^8.0.0, whatwg-url@^8.5.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.6.0.tgz#27c0205a4902084b872aecb97cf0f2a7a3011f4c" + integrity sha512-os0KkeeqUOl7ccdDT1qqUcS4KH4tcBTSKK5Nl5WKb2lyxInIZ/CpjkqKa1Ss12mjfdcRX9mHmPPs7/SxG1Hbdw== + dependencies: + lodash "^4.7.0" + tr46 "^2.1.0" + webidl-conversions "^6.1.0" + +which-boxed-primitive@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + +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, which@^1.2.9: + 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" + +which@2.0.2, which@^2.0.1, which@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wide-align@1.1.3, 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" + +windows-release@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-4.0.0.tgz#4725ec70217d1bf6e02c7772413b29cdde9ec377" + integrity sha512-OxmV4wzDKB1x7AZaZgXMVsdJ1qER1ed83ZrTYd5Bwq2HfJVg3DJS8nqlAG4sMoJ7mu8cuRmLEYyU13BKwctRAg== + dependencies: + execa "^4.0.2" + +winston-daily-rotate-file@^4.5.0: + version "4.5.5" + resolved "https://registry.yarnpkg.com/winston-daily-rotate-file/-/winston-daily-rotate-file-4.5.5.tgz#cfa3a89f4eb0e4126917592b375759b772bcd972" + integrity sha512-ds0WahIjiDhKCiMXmY799pDBW+58ByqIBtUcsqr4oDoXrAI3Zn+hbgFdUxzMfqA93OG0mPLYVMiotqTgE/WeWQ== + dependencies: + file-stream-rotator "^0.5.7" + object-hash "^2.0.1" + triple-beam "^1.3.0" + winston-transport "^4.4.0" + +winston-transport@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59" + integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw== + dependencies: + readable-stream "^2.3.7" + triple-beam "^1.2.0" + +winston@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170" + integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw== + dependencies: + "@dabh/diagnostics" "^2.0.2" + async "^3.1.0" + is-stream "^2.0.0" + logform "^2.2.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.4.0" + +word-wrap@^1.2.3, word-wrap@~1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" + integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + +workerpool@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.0.tgz#a8e038b4c94569596852de7a8ea4228eefdeb37b" + integrity sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg== + +wrap-ansi@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" + integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q== + dependencies: + ansi-styles "^3.2.0" + string-width "^3.0.0" + strip-ansi "^5.0.0" + +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +write-file-atomic@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + +ws@^7.4.5: + version "7.5.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.0.tgz#0033bafea031fb9df041b2026fc72a571ca44691" + integrity sha512-6ezXvzOZupqKj4jUqbQ9tXuJNo+BR2gU8fFRk3XCP3e0G6WT414u5ELe6Y0vtp7kmSJ3F7YWObSNr1ESsgi4vw== + +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== + +xml2js@^0.4.23: + version "0.4.23" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xml@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" + integrity sha1-eLpyAgApxbyHuKgaPPzXS0ovweU= + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.0, yallist@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yaml@^1.7.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + +yargonaut@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/yargonaut/-/yargonaut-1.1.4.tgz#c64f56432c7465271221f53f5cc517890c3d6e0c" + integrity sha512-rHgFmbgXAAzl+1nngqOcwEljqHGG9uUZoPjsdZEs1w5JW9RXYzrSvH/u70C1JE5qFi0qjsdhnUX/dJRpWqitSA== + dependencies: + chalk "^1.1.1" + figlet "^1.1.1" + parent-require "^1.0.0" + +yargs-parser@20.2.4: + version "20.2.4" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== + +yargs-parser@20.x, yargs-parser@^20.2.2: + version "20.2.7" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.7.tgz#61df85c113edfb5a7a4e36eb8aa60ef423cbc90a" + integrity sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw== + +yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs-unparser@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@16.2.0, yargs@^16.0.0, yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yargs@^13.3.0: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + +yargs@^15.0.2, yargs@^15.4.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + +yn@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yn/-/yn-2.0.0.tgz#e5adabc8acf408f6385fc76495684c88e6af689a" + integrity sha1-5a2ryKz0CPY4X8dklWhMiOavaJo= + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zen-observable-ts@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-1.0.0.tgz#30d1202b81d8ba4c489e3781e8ca09abf0075e70" + integrity sha512-KmWcbz+9kKUeAQ8btY8m1SsEFgBcp7h/Uf3V5quhan7ZWdjGsf0JcGLULQiwOZibbFWnHkYq8Nn2AZbJabovQg== + dependencies: + "@types/zen-observable" "^0.8.2" + zen-observable "^0.8.15" + +zen-observable@^0.8.15: + version "0.8.15" + resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" + integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== diff --git a/redisinsight/index.html b/redisinsight/index.html new file mode 100644 index 0000000000..a9aa8b600b --- /dev/null +++ b/redisinsight/index.html @@ -0,0 +1,47 @@ + + + + + RedisInsight + + + +
+ + + diff --git a/redisinsight/main.dev.ts b/redisinsight/main.dev.ts new file mode 100644 index 0000000000..96cc4c6103 --- /dev/null +++ b/redisinsight/main.dev.ts @@ -0,0 +1,359 @@ +/* eslint global-require: off, no-console: off */ + +/** + * This module executes inside of electron's main process. You can start + * electron renderer process from here and communicate with the other processes + * through IPC. + * + * When running `yarn build` or `yarn build-main`, this file is compiled to + * `../ui/main.prod.js` using webpack. This gives us some performance wins. + */ +import 'core-js/stable'; +import 'regenerator-runtime/runtime'; +import path from 'path'; +import { + app, + BrowserWindow, + nativeTheme, + shell, + dialog, + ipcMain, + Tray, +} from 'electron'; +import { autoUpdater, UpdateDownloadedEvent } from 'electron-updater'; +import log from 'electron-log'; +import installExtension, { + REDUX_DEVTOOLS, + REACT_DEVELOPER_TOOLS, +} from 'electron-devtools-installer'; +import Store from 'electron-store'; +import detectPort from 'detect-port'; +import contextMenu from 'electron-context-menu'; +// eslint-disable-next-line import/no-cycle +import MenuBuilder from './menu'; +import AboutPanelOptions from './about-panel'; +// eslint-disable-next-line import/no-cycle +import TrayBuilder from './tray'; +import server from './api/dist/src/main'; +import { ElectronStorageItem, ipcEvent } from './ui/src/electron/constants'; + +if (process.env.NODE_ENV !== 'production') { + log.transports.file.getFile().clear(); +} + +log.info('App starting.....'); + +export default class AppUpdater { + constructor() { + log.info('AppUpdater initialization'); + log.transports.file.level = 'info'; + + autoUpdater.setFeedURL({ + provider: 's3', + path: 'public/upgrades/', + bucket: process.env.MANUAL_UPDATE_BUCKET || process.env.AWS_BUCKET_NAME, + region: 'us-east-1', + }); + + autoUpdater.checkForUpdatesAndNotify(); + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + } +} + +if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line import/no-extraneous-dependencies + const sourceMapSupport = require('source-map-support'); + sourceMapSupport.install(); +} + +const installExtensions = async () => { + const extensions = [REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]; + const forceDownload = !!process.env.UPGRADE_EXTENSIONS; + + return installExtension(extensions, { + forceDownload, + loadExtensionOptions: { allowFileAccess: true }, + }) + .then((name) => console.log(`Added Extension: ${name}`)) + .catch((err) => console.log('An error occurred: ', err.toString())); +}; + +let store: Store; +let tray: TrayBuilder; +let trayInstance: Tray; +let isQuiting = false; + +export const getDisplayAppInTrayValue = (): boolean => { + if (process.platform === 'linux') { + return false; + } + return !!store?.get(ElectronStorageItem.isDisplayAppInTray); +}; + +/** + * Backend part... + */ +const port = 5000; +const launchApiServer = async () => { + try { + const detectPortConst = await detectPort(port); + process.env.API_PORT = detectPortConst?.toString(); + log.info('Available port:', detectPortConst); + server(); + } catch (error) { + log.error('Catch server error:', error); + } +}; + +const bootstrap = async () => { + await launchApiServer(); + nativeTheme.themeSource = 'dark'; + + store = new Store(); + + if (getDisplayAppInTrayValue()) { + tray = new TrayBuilder(); + trayInstance = tray.buildTray(); + } + + if (process.env.NODE_ENV === 'production') { + new AppUpdater(); + } + + app.setName('RedisInsight'); + app.setAppUserModelId('RedisInsight-preview'); + if (process.platform !== 'darwin') { + app.setAboutPanelOptions(AboutPanelOptions); + } + + if (process.env.NODE_ENV !== 'production') { + await installExtensions(); + } +}; + +export const windows = new Set(); + +export const createWindow = async () => { + const RESOURCES_PATH = app.isPackaged + ? path.join(process.resourcesPath, 'resources') + : path.join(__dirname, '../resources'); + + const getAssetPath = (...paths: string[]): string => { + return path.join(RESOURCES_PATH, ...paths); + }; + + let x; + let y; + const currentWindow = BrowserWindow.getFocusedWindow(); + if (currentWindow) { + const [currentWindowX, currentWindowY] = currentWindow.getPosition(); + x = currentWindowX + 24; + y = currentWindowY + 24; + } + let newWindow: BrowserWindow | null = new BrowserWindow({ + x, + y, + show: false, + width: 1300, + height: 860, + minHeight: 540, + minWidth: 720, + // frame: process.platform === 'darwin', + // titleBarStyle: 'hidden', + icon: getAssetPath('icon.png'), + webPreferences: { + nodeIntegration: true, + nodeIntegrationInWorker: true, + webSecurity: false, + contextIsolation: false, + spellcheck: true, + allowRunningInsecureContent: true, + enableRemoteModule: true, + scrollBounce: true, + }, + }); + + newWindow.loadURL(`file://${__dirname}/index.html`); + + newWindow.webContents.on('did-finish-load', () => { + if (!newWindow) { + throw new Error('"newWindow" is not defined'); + } + + if (!trayInstance?.isDestroyed()) { + tray?.updateTooltip(newWindow.webContents.getTitle()); + } + + if (process.env.START_MINIMIZED) { + newWindow.minimize(); + } else { + newWindow.show(); + newWindow.focus(); + } + }); + + newWindow.on('page-title-updated', () => { + if (newWindow && !trayInstance?.isDestroyed()) { + tray?.updateTooltip(newWindow.webContents.getTitle()); + tray?.buildContextMenu(); + } + }); + + newWindow.on('close', (event) => { + if (!isQuiting && getDisplayAppInTrayValue() && windows.size === 1) { + event.preventDefault(); + newWindow?.hide(); + app.dock?.hide(); + } + }); + + newWindow.on('closed', () => { + if (newWindow) { + windows.delete(newWindow); + newWindow = null; + } + + if (!trayInstance?.isDestroyed()) { + tray?.buildContextMenu(); + } + }); + + newWindow.on('focus', () => { + if (newWindow) { + const menuBuilder = new MenuBuilder(newWindow); + menuBuilder.buildMenu(); + + if (!trayInstance?.isDestroyed()) { + tray?.updateTooltip(newWindow.webContents.getTitle()); + } + } + }); + + // Open urls in the user's browser + newWindow.webContents.on('new-window', (event, url) => { + event.preventDefault(); + shell.openExternal(url); + }); + + // event newWindow.webContents.on('context-menu', ...) + contextMenu({ window: newWindow, showInspectElement: true }); + + windows.add(newWindow); + if (!trayInstance?.isDestroyed()) { + tray?.buildContextMenu(); + tray?.updateTooltip(newWindow.webContents.getTitle()); + } + + return newWindow; +}; + +export const getWindows = () => windows; + +export const updateDisplayAppInTray = (value: boolean) => { + store?.set(ElectronStorageItem.isDisplayAppInTray, value); + if (!value) { + trayInstance?.destroy(); + return; + } + tray = new TrayBuilder(); + trayInstance = tray.buildTray(); + + const currentWindow = BrowserWindow.getFocusedWindow(); + if (currentWindow) { + tray.updateTooltip(currentWindow.webContents.getTitle()); + } +}; + +export const setToQuiting = () => { + isQuiting = true; +}; + +/** + * Add event listeners... + */ + +app.on('window-all-closed', () => { + log.info('window-all-closed'); + // Respect the OSX convention of having the application in memory even + // after all windows have been closed + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('continue-activity-error', (event, type, error) => { + log.info('event', event); + log.info('type', type); + log.info('error', error); + // Respect the OSX convention of having the application in memory even + // after all windows have been closed + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.whenReady().then(bootstrap).then(createWindow).catch(console.log); + +app.on('activate', () => { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (windows.size === 0) createWindow(); +}); + +function sendStatusToWindow(text: string) { + log.info(text); + // newWindow?.webContents.send('message', text); +} + +autoUpdater.on('checking-for-update', () => { + sendStatusToWindow('Checking for update...'); +}); +autoUpdater.on('update-available', () => { + sendStatusToWindow('Update available.'); + store?.set(ElectronStorageItem.isUpdateAvailable, true); +}); +autoUpdater.on('update-not-available', () => { + sendStatusToWindow('Update not available.'); + store?.set(ElectronStorageItem.isUpdateAvailable, false); +}); +autoUpdater.on('error', (err: string) => { + sendStatusToWindow(`Error in auto-updater. ${err}`); +}); +autoUpdater.on('download-progress', (progressObj) => { + let logMessage = `Download speed: ${progressObj.bytesPerSecond}`; + logMessage += ` - Downloaded ${progressObj.percent}%`; + logMessage += ` (${progressObj.transferred}/${progressObj.total})`; + sendStatusToWindow(logMessage); +}); +autoUpdater.on('update-downloaded', (info: UpdateDownloadedEvent) => { + sendStatusToWindow('Update downloaded'); + log.info('releaseNotes', info.releaseNotes); + log.info('releaseDate', info.releaseDate); + log.info('releaseName', info.releaseName); + log.info('version', info.version); + log.info('files', info.files); + + // set updateDownloaded to electron storage for Telemetry send event APPLICATION_UPDATED + store?.set(ElectronStorageItem.updateDownloaded, true); + store?.set(ElectronStorageItem.updateDownloadedForTelemetry, true); + store?.set(ElectronStorageItem.updateDownloadedVersion, info.version); + store?.set(ElectronStorageItem.updatePreviousVersion, app.getVersion()); + + log.info('Path to downloaded file: ', info.downloadedFile); +}); + +app.on('certificate-error', (event, _webContents, _url, _error, _certificate, callback) => { + // Skip error due to self-signed certificate + event.preventDefault(); + callback(true); +}); + +// ipc events +ipcMain.handle(ipcEvent.getStoreValue, (_event, key) => store?.get(key)); + +ipcMain.handle(ipcEvent.deleteStoreValue, (_event, key) => store?.delete(key)); + +dialog.showErrorBox = (title: string, content: string) => { + log.error('Dialog shows error:', `\n${title}\n${content}`); +}; diff --git a/redisinsight/main.prod.js.LICENSE.txt b/redisinsight/main.prod.js.LICENSE.txt new file mode 100644 index 0000000000..969b42efcf --- /dev/null +++ b/redisinsight/main.prod.js.LICENSE.txt @@ -0,0 +1,327 @@ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +/*! + * FileStreamRotator + * Copyright(c) 2012-2017 Holiday Extras. + * Copyright(c) 2017 Roger C. + * MIT Licensed + */ + +/*! + * accepts + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * body-parser + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * body-parser + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * bytes + * Copyright(c) 2012-2014 TJ Holowaychuk + * Copyright(c) 2015 Jed Watson + * MIT Licensed + */ + +/*! + * content-disposition + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * content-type + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * cookie + * Copyright(c) 2012-2014 Roman Shtylman + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * depd + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * depd + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * depd + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * depd + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * destroy + * Copyright(c) 2014 Jonathan Ong + * MIT Licensed + */ + +/*! + * ee-first + * Copyright(c) 2014 Jonathan Ong + * MIT Licensed + */ + +/*! + * encodeurl + * Copyright(c) 2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ + +/*! + * etag + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * express + * Copyright(c) 2009-2013 TJ Holowaychuk + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * express + * Copyright(c) 2009-2013 TJ Holowaychuk + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * finalhandler + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * forwarded + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * fresh + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2016-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * http-errors + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * media-typer + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * merge-descriptors + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * methods + * Copyright(c) 2013-2014 TJ Holowaychuk + * Copyright(c) 2015-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * mime-db + * Copyright(c) 2014 Jonathan Ong + * MIT Licensed + */ + +/*! + * mime-types + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * negotiator + * Copyright(c) 2012 Federico Romero + * Copyright(c) 2012-2014 Isaac Z. Schlueter + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * on-finished + * Copyright(c) 2013 Jonathan Ong + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * parseurl + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * proxy-addr + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * range-parser + * Copyright(c) 2012-2014 TJ Holowaychuk + * Copyright(c) 2015-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * raw-body + * Copyright(c) 2013-2014 Jonathan Ong + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * send + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * serve-static + * Copyright(c) 2010 Sencha Inc. + * Copyright(c) 2011 TJ Holowaychuk + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * statuses + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * toidentifier + * Copyright(c) 2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * type-is + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * unpipe + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * vary + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + */ + +/*! ***************************************************************************** +Copyright (C) Microsoft. 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 + +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. + +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ + +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ + +/*! http://mths.be/fromcodepoint v0.1.0 by @mathias */ + +/*! safe-buffer. MIT License. Feross Aboukhadijeh */ + +/** + * @license + * Lodash + * Copyright OpenJS Foundation and other contributors + * Released under MIT license + * Based on Underscore.js 1.8.3 + * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + */ + +//! moment.js + +//! moment.js locale configuration diff --git a/redisinsight/main.renderer.ts b/redisinsight/main.renderer.ts new file mode 100644 index 0000000000..9ab5f32874 --- /dev/null +++ b/redisinsight/main.renderer.ts @@ -0,0 +1,8 @@ +import { Titlebar, Color } from 'custom-electron-titlebar'; + +const MyTitleBar = new Titlebar({ + backgroundColor: Color.fromHex('#101317'), + shadow: true, +}); + +MyTitleBar.updateTitle('RedisInsight'); diff --git a/redisinsight/menu.ts b/redisinsight/menu.ts new file mode 100644 index 0000000000..2522cff033 --- /dev/null +++ b/redisinsight/menu.ts @@ -0,0 +1,287 @@ +import { + app, + Menu, + shell, + BrowserWindow, + MenuItemConstructorOptions, + MenuItem, +} from 'electron'; +// eslint-disable-next-line import/no-cycle +import { createWindow, getDisplayAppInTrayValue, updateDisplayAppInTray } from './main.dev'; + +interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { + selector?: string; + submenu?: DarwinMenuItemConstructorOptions[] | Menu; +} + +export default class MenuBuilder { + public mainWindow: BrowserWindow; + + constructor(mainWindow: BrowserWindow) { + this.mainWindow = mainWindow; + } + + buildMenu(): Menu { + const template = process.platform === 'darwin' + ? this.buildDarwinTemplate() + : this.buildDefaultTemplate(); + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); + + return menu; + } + + buildDarwinTemplate(): MenuItemConstructorOptions[] { + const subMenuApp: DarwinMenuItemConstructorOptions = { + label: app.name, + submenu: [ + { + label: `About ${app.name}`, + selector: 'orderFrontStandardAboutPanel:', + }, + { type: 'separator' }, + { + label: `Hide ${app.name}`, + accelerator: 'Command+H', + selector: 'hide:', + }, + { + label: 'Hide Others', + accelerator: 'Command+Shift+H', + selector: 'hideOtherApplications:', + }, + { label: 'Show All', selector: 'unhideAllApplications:' }, + { type: 'separator' }, + { + label: 'Quit', + accelerator: 'Command+Q', + click: () => { + app.quit(); + }, + }, + ], + }; + const subMenuEdit: DarwinMenuItemConstructorOptions = { + label: 'Edit', + submenu: [ + { label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' }, + { label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' }, + { type: 'separator' }, + { label: 'Cut', accelerator: 'Command+X', selector: 'cut:' }, + { label: 'Copy', accelerator: 'Command+C', selector: 'copy:' }, + { label: 'Paste', accelerator: 'Command+V', selector: 'paste:' }, + { + label: 'Select All', + accelerator: 'Command+A', + selector: 'selectAll:', + }, + ], + }; + const subMenuView: MenuItemConstructorOptions = { + label: 'View', + submenu: [ + { + label: 'Reload', + accelerator: 'Command+R', + click: () => { + this.mainWindow.webContents.reload(); + }, + }, + { type: 'separator' }, + { + label: 'Toggle Full Screen', + accelerator: 'Ctrl+Command+F', + click: () => { + this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); + }, + }, + { + label: 'Toggle Developer Tools', + accelerator: 'Alt+Command+I', + click: () => { + this.mainWindow.webContents.toggleDevTools(); + }, + }, + { type: 'separator' }, + { role: 'resetZoom', label: 'Reset Zoom' }, + { role: 'zoomIn', visible: false }, + { + role: 'zoomIn', + accelerator: 'CmdOrCtrl+=', + }, + { role: 'zoomOut' }, + ], + }; + const subMenuWindow: DarwinMenuItemConstructorOptions = { + label: 'Window', + submenu: [ + { + label: 'New Window', + accelerator: 'Command+N', + click: () => { + createWindow(); + }, + }, + { + label: 'Minimize', + accelerator: 'Command+M', + selector: 'performMiniaturize:', + }, + { + label: 'Close', + accelerator: 'Command+W', + click: () => { + this.mainWindow.close(); + }, + }, + { + type: 'separator', + }, + { + label: 'Display On System Tray', + type: 'checkbox', + checked: getDisplayAppInTrayValue(), + click: (menuItem: MenuItem) => { + updateDisplayAppInTray(menuItem.checked); + }, + }, + // { type: 'separator' }, + // { label: 'Bring All to Front', selector: 'arrangeInFront:' }, + ], + }; + const subMenuHelp: MenuItemConstructorOptions = { + label: 'Help', + submenu: [ + { + label: 'License Terms', + click() { + shell.openExternal('https://github.com/RedisInsight/RedisInsight/blob/master/LICENSE'); + }, + }, + { + label: 'Submit a Bug or Idea', + click() { + shell.openExternal('https://github.com/RedisInsight/RedisInsight/issues'); + }, + }, + { + label: 'Learn More', + click() { + shell.openExternal('https://docs.redis.com/latest/ri/'); + }, + }, + ], + }; + + return [subMenuApp, subMenuEdit, subMenuWindow, subMenuView, subMenuHelp]; + } + + buildDefaultTemplate() { + const templateDefault = [ + { + label: '&Window', + submenu: [ + { + label: 'New Window', + accelerator: 'Ctrl+N', + click: () => { + createWindow(); + }, + }, + { + label: '&Close', + accelerator: 'Ctrl+W', + click: () => { + this.mainWindow.close(); + }, + }, + // type separator cannot be invisible + { + label: '', + type: process.platform !== 'linux' ? 'separator' : 'normal', + visible: false, + }, + { + label: 'Display On System Tray', + type: 'checkbox', + visible: process.platform !== 'linux', + checked: getDisplayAppInTrayValue(), + click: (menuItem: MenuItem) => { + updateDisplayAppInTray(menuItem.checked); + }, + }, + ], + }, + { + label: '&View', + submenu: [ + { + label: '&Reload', + accelerator: 'Ctrl+R', + click: () => { + this.mainWindow.webContents.reload(); + }, + }, + { type: 'separator' }, + { + label: 'Toggle &Full Screen', + accelerator: 'F11', + click: () => { + this.mainWindow.setFullScreen( + !this.mainWindow.isFullScreen(), + ); + }, + }, + { + label: 'Toggle &Developer Tools', + accelerator: 'Ctrl+Shift+I', + click: () => { + this.mainWindow.webContents.toggleDevTools(); + }, + }, + { type: 'separator' }, + { role: 'resetZoom', label: 'Reset Zoom' }, + { role: 'zoomIn', visible: false }, + { + role: 'zoomIn', + accelerator: 'CmdOrCtrl+=', + }, + { role: 'zoomOut' }, + ], + }, + { + label: 'Help', + submenu: [ + { + label: 'License Terms', + click() { + shell.openExternal('https://github.com/RedisInsight/RedisInsight/blob/master/LICENSE'); + }, + }, + { + label: 'Submit a Bug or Idea', + click() { + shell.openExternal('https://github.com/RedisInsight/RedisInsight/issues'); + }, + }, + { + label: 'Learn More', + click() { + shell.openExternal('https://docs.redis.com/latest/ri/'); + }, + }, + { type: 'separator' }, + { + label: `About ${app.name}`, + click: () => { + app.showAboutPanel(); + }, + }, + ], + }, + ]; + + return templateDefault; + } +} diff --git a/redisinsight/package.json b/redisinsight/package.json new file mode 100644 index 0000000000..d3cbfca0e5 --- /dev/null +++ b/redisinsight/package.json @@ -0,0 +1,19 @@ +{ + "name": "redisinsight", + "productName": "RedisInsight", + "private": true, + "version": "2.0.2-preview", + "description": "RedisInsight", + "main": "./main.prod.js", + "author": { + "name": "Redis Ltd.", + "email": "support@redis.com", + "url": "https://redis.com/redis-enterprise/redis-insight" + }, + "scripts": {}, + "dependencies": { + "jsonpath": "^1.1.1", + "keytar": "^7.7.0", + "sqlite3": "^5.0.2" + } +} diff --git a/redisinsight/tray.ts b/redisinsight/tray.ts new file mode 100644 index 0000000000..90522eb3fb --- /dev/null +++ b/redisinsight/tray.ts @@ -0,0 +1,120 @@ +import { + app, + Menu, + shell, + Tray, + nativeImage, + BrowserWindow, + MenuItemConstructorOptions, +} from 'electron'; +import path from 'path'; +// eslint-disable-next-line import/no-cycle +import { createWindow, setToQuiting, windows } from './main.dev'; + +export default class TrayBuilder { + public tray: Tray; + + constructor() { + const iconRelevantPath = process.platform === 'darwin' + ? '../resources/icon-tray-white.png' + : '../resources/icon-tray-colored.png'; + const iconPath = path.join(__dirname, iconRelevantPath); + const icon = nativeImage.createFromPath(iconPath); + const iconTray = icon.resize({ height: 16, width: 16 }); + iconTray.setTemplateImage(true); + + this.tray = new Tray(iconTray); + } + + buildOpenAppSubMenu() { + if (windows.size > 1) { + return { + label: 'Open RedisInsight', + type: 'submenu', + submenu: [ + { + label: 'All', + click: () => { + this.openApp(); + }, + }, + { + type: 'separator', + }, + ...[...windows].map((window) => ({ + label: window.webContents.getTitle(), + click: () => { + window.show(); + }, + })), + ], + }; + } + + return { + label: 'Open RedisInsight', + click: () => { + this.openApp(); + }, + }; + } + + buildContextMenu() { + const contextMenu = Menu.buildFromTemplate([ + this.buildOpenAppSubMenu(), + { type: 'separator' }, + { + label: 'About', + click: () => { + this.openApp(); + + app.showAboutPanel(); + }, + }, + { + label: 'Learn More', + click() { + shell.openExternal('https://docs.redis.com/latest/ri/'); + }, + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + setToQuiting(); + app.quit(); + }, + }, + ] as MenuItemConstructorOptions[]); + + this.tray.setContextMenu(contextMenu); + } + + buildTray() { + this.tray.setToolTip(app.name); + this.buildContextMenu(); + + if (process.platform !== 'darwin') { + this.tray.on('click', () => { + this.openApp(); + }); + } + + return this.tray; + } + + updateTooltip(name: string) { + this.tray.setToolTip(name); + } + + private openApp() { + if (windows.size) { + windows.forEach((window: BrowserWindow) => window.show()); + app.dock?.show(); + } + + if (!windows.size) { + createWindow(); + } + } +} diff --git a/redisinsight/ui/.eslintignore b/redisinsight/ui/.eslintignore new file mode 100644 index 0000000000..1521c8b765 --- /dev/null +++ b/redisinsight/ui/.eslintignore @@ -0,0 +1 @@ +dist diff --git a/redisinsight/ui/.eslintrc.js b/redisinsight/ui/.eslintrc.js new file mode 100644 index 0000000000..bb7a5d3698 --- /dev/null +++ b/redisinsight/ui/.eslintrc.js @@ -0,0 +1,64 @@ +const path = require('path') + +module.exports = { + root: true, + env: { + browser: true, + }, + extends: ['airbnb-typescript', 'airbnb/hooks', 'plugin:sonarjs/recommended'], + // extends: ['airbnb', 'airbnb/hooks'], + plugins: ['@typescript-eslint'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + project: path.join(__dirname, '../../tsconfig.json'), + createDefaultProgram: true, + }, + + overrides: [ + { + files: [ + '**/*.spec.ts', + '**/*.spec.tsx', + '**/*.spec.ts', + ], + env: { + jest: true, + }, + }, + ], + ignorePatterns: ['dist', 'src/packages/redisearch/src/icons/*.js', 'src/packages/clients-list-example/src/icons/*.js'], + + rules: { + radix: 'off', + semi: ['error', 'never'], + 'no-bitwise': ['error', { allow: ['|'] }], + 'max-len': ['error', { ignoreComments: true, ignoreStrings: true, ignoreRegExpLiterals: true, code: 110 }], + 'class-methods-use-this': 'off', + // A temporary hack related to IDE not resolving correct package.json + 'import/no-extraneous-dependencies': 'off', + 'import/prefer-default-export': 'off', + 'import/no-cycle': 'off', + 'import/no-named-as-default-member': 'off', + 'no-plusplus': 'off', + 'no-return-await': 'off', + 'no-underscore-dangle': 'off', + 'no-useless-catch': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + 'max-classes-per-file': 'off', + 'no-case-declarations': 'off', + 'react-hooks/exhaustive-deps': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/require-default-props': 'off', + '@typescript-eslint/comma-dangle': 'off', + '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/semi': ['error', 'never'], + '@typescript-eslint/no-use-before-define': 'off', + 'implicit-arrow-linebreak': 'off', + 'object-curly-newline': 'off', + 'no-nested-ternary': 'off', + 'no-param-reassign': ['error', { props: false }] + }, +} diff --git a/redisinsight/ui/README.md b/redisinsight/ui/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/redisinsight/ui/index.html.ejs b/redisinsight/ui/index.html.ejs new file mode 100644 index 0000000000..5245d27c17 --- /dev/null +++ b/redisinsight/ui/index.html.ejs @@ -0,0 +1,23 @@ + + + + + + RedisInsight + + + +
+ + + <% if (webpackConfig.mode=='production' ) { %> + + + <% } else { %> + + <% } %> + + diff --git a/redisinsight/ui/index.tsx b/redisinsight/ui/index.tsx new file mode 100644 index 0000000000..3c08906230 --- /dev/null +++ b/redisinsight/ui/index.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { render } from 'react-dom' +import App from 'uiSrc/App' +import { listenPluginsEvents } from 'uiSrc/plugins/pluginEvents' +import 'uiSrc/styles/base/_fonts.scss' +import 'uiSrc/styles/main.scss' + +listenPluginsEvents() + +const rootEl = document.getElementById('root') +render(, rootEl) diff --git a/redisinsight/ui/indexElectron.tsx b/redisinsight/ui/indexElectron.tsx new file mode 100644 index 0000000000..81f37a7af5 --- /dev/null +++ b/redisinsight/ui/indexElectron.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { render } from 'react-dom' +import AppElectron from 'uiSrc/electron/AppElectron' +import { listenPluginsEvents } from 'uiSrc/plugins/pluginEvents' +import 'uiSrc/styles/base/_fonts.scss' +import 'uiSrc/styles/main.scss' + +listenPluginsEvents() + +const rootEl = document.getElementById('root') +render(, rootEl) diff --git a/redisinsight/ui/src/App.scss b/redisinsight/ui/src/App.scss new file mode 100644 index 0000000000..62bf3e8dad --- /dev/null +++ b/redisinsight/ui/src/App.scss @@ -0,0 +1,57 @@ +/* + * @NOTE: Prepend a `~` to css file paths that are in your node_modules + * See https://github.com/webpack-contrib/sass-loader#imports + */ + +@import '@elastic/eui/src/global_styling/mixins/helpers'; +@import '@elastic/eui/src/components/table/mixins'; +@import '@elastic/eui/src/global_styling/index'; + +html { + background-color: var(--euiPageBackgroundColor); +} + +body { + @include euiScrollBar; + -webkit-font-smoothing: antialiased; +} + +.main-container { + display: flex; + height: 100vh; + .main { + flex: 1; + padding: 0; + } +} + +input[type='number']::-webkit-outer-spin-button, +input[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type='number'] { + -moz-appearance: textfield; +} + +.euiScreenReaderOnly { + // position: absolute !important; +} + +.euiGlobalToastList { + bottom: 68px !important; +} + +.euiToast { + background-color: var(--euiColorLightestShade) !important; + + &--success { + background-color: var(--euiToastBackgroundColor) !important; + } +} + +// Fix for resolve conflict Elasti Tooltips and package 'custom-electron-titlebar' +.electron.euiBody-hasPortalContent { + position: initial !important; +} diff --git a/redisinsight/ui/src/App.spec.tsx b/redisinsight/ui/src/App.spec.tsx new file mode 100644 index 0000000000..9c46aee149 --- /dev/null +++ b/redisinsight/ui/src/App.spec.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' + +import App from './App' + +describe('App', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/App.tsx b/redisinsight/ui/src/App.tsx new file mode 100644 index 0000000000..63cc50a085 --- /dev/null +++ b/redisinsight/ui/src/App.tsx @@ -0,0 +1,42 @@ +import { hot } from 'react-hot-loader/root' +import React, { ReactElement } from 'react' +import { Provider } from 'react-redux' +import { EuiPage, EuiPageBody } from '@elastic/eui' + +import Router from './Router' +import store from './slices/store' +import { Theme } from './constants' +import { themeService } from './services' +import { NavigationMenu, Notifications, Config } from './components' +import { ThemeProvider } from './contexts/themeContext' +import MainComponent from './components/main/MainComponent' + +import themeDark from './styles/themes/dark_theme/_dark_theme.lazy.scss' +import themeLight from './styles/themes/light_theme/_light_theme.lazy.scss' + +import './App.scss' + +themeService.registerTheme(Theme.Dark, [themeDark]) +themeService.registerTheme(Theme.Light, [themeLight]) + +const App = ({ children }: { children?: ReactElement }) => ( + + + +
+ + + + + + + + +
+
+
+ {children} +
+) + +export default hot(App) diff --git a/redisinsight/ui/src/Router.spec.tsx b/redisinsight/ui/src/Router.spec.tsx new file mode 100644 index 0000000000..3adf041043 --- /dev/null +++ b/redisinsight/ui/src/Router.spec.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' + +import Router from './Router' + +describe('Router', () => { + it('should render', () => { + expect( + render( + +
test
+
, + ), + ).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/Router.tsx b/redisinsight/ui/src/Router.tsx new file mode 100644 index 0000000000..18c9099490 --- /dev/null +++ b/redisinsight/ui/src/Router.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { BrowserRouter, HashRouter } from 'react-router-dom' +import { AppEnv } from './constants/env' + +interface Props { + children: React.ReactElement; +} + +const Router = ({ children }: Props) => + (process.env.APP_ENV !== AppEnv.ELECTRON ? ( + {children} + ) : ( + {children} + )) + +export default Router diff --git a/redisinsight/ui/src/assets/assets.d.ts b/redisinsight/ui/src/assets/assets.d.ts new file mode 100644 index 0000000000..ee4bf5071a --- /dev/null +++ b/redisinsight/ui/src/assets/assets.d.ts @@ -0,0 +1,19 @@ +declare module '*.svg' { + const content: string + export default content +} + +declare module '*.ico' { + const content: string + export default content +} + +declare module '*.png' { + const content: string + export default content +} + +declare module '*.jpg' { + const content: string + export default content +} diff --git a/redisinsight/ui/src/assets/favicon.ico b/redisinsight/ui/src/assets/favicon.ico new file mode 100644 index 0000000000..d8b2d6d1c1 Binary files /dev/null and b/redisinsight/ui/src/assets/favicon.ico differ diff --git a/redisinsight/ui/src/assets/fonts/graphik/Graphik-Light.otf b/redisinsight/ui/src/assets/fonts/graphik/Graphik-Light.otf new file mode 100644 index 0000000000..89b54757af Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/graphik/Graphik-Light.otf differ diff --git a/redisinsight/ui/src/assets/fonts/graphik/Graphik-LightItalic.otf b/redisinsight/ui/src/assets/fonts/graphik/Graphik-LightItalic.otf new file mode 100644 index 0000000000..07778948b4 Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/graphik/Graphik-LightItalic.otf differ diff --git a/redisinsight/ui/src/assets/fonts/graphik/Graphik-Medium.otf b/redisinsight/ui/src/assets/fonts/graphik/Graphik-Medium.otf new file mode 100644 index 0000000000..5d510bb174 Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/graphik/Graphik-Medium.otf differ diff --git a/redisinsight/ui/src/assets/fonts/graphik/Graphik-MediumItalic.otf b/redisinsight/ui/src/assets/fonts/graphik/Graphik-MediumItalic.otf new file mode 100644 index 0000000000..5847de0111 Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/graphik/Graphik-MediumItalic.otf differ diff --git a/redisinsight/ui/src/assets/fonts/graphik/Graphik-Regular.otf b/redisinsight/ui/src/assets/fonts/graphik/Graphik-Regular.otf new file mode 100644 index 0000000000..16985093d9 Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/graphik/Graphik-Regular.otf differ diff --git a/redisinsight/ui/src/assets/fonts/graphik/Graphik-RegularItalic.otf b/redisinsight/ui/src/assets/fonts/graphik/Graphik-RegularItalic.otf new file mode 100644 index 0000000000..231c2b6774 Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/graphik/Graphik-RegularItalic.otf differ diff --git a/redisinsight/ui/src/assets/fonts/graphik/Graphik-Semibold.otf b/redisinsight/ui/src/assets/fonts/graphik/Graphik-Semibold.otf new file mode 100644 index 0000000000..1979fd830a Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/graphik/Graphik-Semibold.otf differ diff --git a/redisinsight/ui/src/assets/fonts/graphik/Graphik-SemiboldItalic.otf b/redisinsight/ui/src/assets/fonts/graphik/Graphik-SemiboldItalic.otf new file mode 100644 index 0000000000..25b3933538 Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/graphik/Graphik-SemiboldItalic.otf differ diff --git a/redisinsight/ui/src/assets/fonts/inconsolata/Inconsolata-Bold.ttf b/redisinsight/ui/src/assets/fonts/inconsolata/Inconsolata-Bold.ttf new file mode 100644 index 0000000000..9f92725889 Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/inconsolata/Inconsolata-Bold.ttf differ diff --git a/redisinsight/ui/src/assets/fonts/inconsolata/Inconsolata-Regular.ttf b/redisinsight/ui/src/assets/fonts/inconsolata/Inconsolata-Regular.ttf new file mode 100644 index 0000000000..457d262cf5 Binary files /dev/null and b/redisinsight/ui/src/assets/fonts/inconsolata/Inconsolata-Regular.ttf differ diff --git a/redisinsight/ui/src/assets/img/active_auto.svg b/redisinsight/ui/src/assets/img/active_auto.svg new file mode 100644 index 0000000000..bc73d0dc30 --- /dev/null +++ b/redisinsight/ui/src/assets/img/active_auto.svg @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/active_manual.svg b/redisinsight/ui/src/assets/img/active_manual.svg new file mode 100644 index 0000000000..e135078f2b --- /dev/null +++ b/redisinsight/ui/src/assets/img/active_manual.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/dark_logo.svg b/redisinsight/ui/src/assets/img/dark_logo.svg new file mode 100644 index 0000000000..1074ca8426 --- /dev/null +++ b/redisinsight/ui/src/assets/img/dark_logo.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/light_logo.svg b/redisinsight/ui/src/assets/img/light_logo.svg new file mode 100644 index 0000000000..be8885883d --- /dev/null +++ b/redisinsight/ui/src/assets/img/light_logo.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/light_theme/active_auto.svg b/redisinsight/ui/src/assets/img/light_theme/active_auto.svg new file mode 100644 index 0000000000..c3c98038c2 --- /dev/null +++ b/redisinsight/ui/src/assets/img/light_theme/active_auto.svg @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/light_theme/active_manual.svg b/redisinsight/ui/src/assets/img/light_theme/active_manual.svg new file mode 100644 index 0000000000..8e664b831d --- /dev/null +++ b/redisinsight/ui/src/assets/img/light_theme/active_manual.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/light_theme/n_active_auto.svg b/redisinsight/ui/src/assets/img/light_theme/n_active_auto.svg new file mode 100644 index 0000000000..7fe2bc32e7 --- /dev/null +++ b/redisinsight/ui/src/assets/img/light_theme/n_active_auto.svg @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/light_theme/n_active_manual.svg b/redisinsight/ui/src/assets/img/light_theme/n_active_manual.svg new file mode 100644 index 0000000000..0e98924073 --- /dev/null +++ b/redisinsight/ui/src/assets/img/light_theme/n_active_manual.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/logo.svg b/redisinsight/ui/src/assets/img/logo.svg new file mode 100644 index 0000000000..f5f83db2e7 --- /dev/null +++ b/redisinsight/ui/src/assets/img/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/modules/RedisAIDark.svg b/redisinsight/ui/src/assets/img/modules/RedisAIDark.svg new file mode 100644 index 0000000000..c0364dd290 --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisAIDark.svg @@ -0,0 +1 @@ +Layer 1 \ No newline at end of file diff --git a/redisinsight/ui/src/assets/img/modules/RedisAILight.svg b/redisinsight/ui/src/assets/img/modules/RedisAILight.svg new file mode 100644 index 0000000000..9532433ad4 --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisAILight.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/modules/RedisBloomDark.svg b/redisinsight/ui/src/assets/img/modules/RedisBloomDark.svg new file mode 100644 index 0000000000..2931a5e3e0 --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisBloomDark.svg @@ -0,0 +1 @@ +Layer 1 \ No newline at end of file diff --git a/redisinsight/ui/src/assets/img/modules/RedisBloomLight.svg b/redisinsight/ui/src/assets/img/modules/RedisBloomLight.svg new file mode 100644 index 0000000000..9fb703026b --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisBloomLight.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/modules/RedisGearsDark.svg b/redisinsight/ui/src/assets/img/modules/RedisGearsDark.svg new file mode 100644 index 0000000000..fa93ad6f01 --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisGearsDark.svg @@ -0,0 +1 @@ +Layer 1 \ No newline at end of file diff --git a/redisinsight/ui/src/assets/img/modules/RedisGearsLight.svg b/redisinsight/ui/src/assets/img/modules/RedisGearsLight.svg new file mode 100644 index 0000000000..e18e3c643a --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisGearsLight.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/modules/RedisGraphDark.svg b/redisinsight/ui/src/assets/img/modules/RedisGraphDark.svg new file mode 100644 index 0000000000..0c7caca2e7 --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisGraphDark.svg @@ -0,0 +1 @@ +Layer 1 \ No newline at end of file diff --git a/redisinsight/ui/src/assets/img/modules/RedisGraphLight.svg b/redisinsight/ui/src/assets/img/modules/RedisGraphLight.svg new file mode 100644 index 0000000000..bf64aebb2b --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisGraphLight.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/modules/RedisJSONDark.svg b/redisinsight/ui/src/assets/img/modules/RedisJSONDark.svg new file mode 100644 index 0000000000..47aa6217ee --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisJSONDark.svg @@ -0,0 +1 @@ +Layer 1 \ No newline at end of file diff --git a/redisinsight/ui/src/assets/img/modules/RedisJSONLight.svg b/redisinsight/ui/src/assets/img/modules/RedisJSONLight.svg new file mode 100644 index 0000000000..3d95bb6f8f --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisJSONLight.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/modules/RedisSearchDark.svg b/redisinsight/ui/src/assets/img/modules/RedisSearchDark.svg new file mode 100644 index 0000000000..5dd744328b --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisSearchDark.svg @@ -0,0 +1 @@ +Layer 1 \ No newline at end of file diff --git a/redisinsight/ui/src/assets/img/modules/RedisSearchLight.svg b/redisinsight/ui/src/assets/img/modules/RedisSearchLight.svg new file mode 100644 index 0000000000..94628a7d60 --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisSearchLight.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/modules/RedisTimeSeriesDark.svg b/redisinsight/ui/src/assets/img/modules/RedisTimeSeriesDark.svg new file mode 100644 index 0000000000..d612dde4cd --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisTimeSeriesDark.svg @@ -0,0 +1 @@ +Layer 1 \ No newline at end of file diff --git a/redisinsight/ui/src/assets/img/modules/RedisTimeSeriesLight.svg b/redisinsight/ui/src/assets/img/modules/RedisTimeSeriesLight.svg new file mode 100644 index 0000000000..b91fdde77e --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/RedisTimeSeriesLight.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/modules/UnknownDark.svg b/redisinsight/ui/src/assets/img/modules/UnknownDark.svg new file mode 100644 index 0000000000..74d4944796 --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/UnknownDark.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/redisinsight/ui/src/assets/img/modules/UnknownLight.svg b/redisinsight/ui/src/assets/img/modules/UnknownLight.svg new file mode 100644 index 0000000000..00c6132be2 --- /dev/null +++ b/redisinsight/ui/src/assets/img/modules/UnknownLight.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/redisinsight/ui/src/assets/img/not_active_auto.svg b/redisinsight/ui/src/assets/img/not_active_auto.svg new file mode 100644 index 0000000000..7c4d4af8f3 --- /dev/null +++ b/redisinsight/ui/src/assets/img/not_active_auto.svg @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/not_active_manual.svg b/redisinsight/ui/src/assets/img/not_active_manual.svg new file mode 100644 index 0000000000..4875892874 --- /dev/null +++ b/redisinsight/ui/src/assets/img/not_active_manual.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/options/Active-ActiveDark.svg b/redisinsight/ui/src/assets/img/options/Active-ActiveDark.svg new file mode 100644 index 0000000000..93a1c0326d --- /dev/null +++ b/redisinsight/ui/src/assets/img/options/Active-ActiveDark.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/options/Active-ActiveLight.svg b/redisinsight/ui/src/assets/img/options/Active-ActiveLight.svg new file mode 100644 index 0000000000..fc5aa2a8a0 --- /dev/null +++ b/redisinsight/ui/src/assets/img/options/Active-ActiveLight.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/options/RedisOnFlashDark.svg b/redisinsight/ui/src/assets/img/options/RedisOnFlashDark.svg new file mode 100644 index 0000000000..3f69fc485c --- /dev/null +++ b/redisinsight/ui/src/assets/img/options/RedisOnFlashDark.svg @@ -0,0 +1,3 @@ + + diff --git a/redisinsight/ui/src/assets/img/options/RedisOnFlashLight.svg b/redisinsight/ui/src/assets/img/options/RedisOnFlashLight.svg new file mode 100644 index 0000000000..1049ad4bac --- /dev/null +++ b/redisinsight/ui/src/assets/img/options/RedisOnFlashLight.svg @@ -0,0 +1 @@ + diff --git a/redisinsight/ui/src/assets/img/overview/input_light.svg b/redisinsight/ui/src/assets/img/overview/input_light.svg new file mode 100644 index 0000000000..451312614d --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/input_light.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/input_tip.svg b/redisinsight/ui/src/assets/img/overview/input_tip.svg new file mode 100644 index 0000000000..0ca661d6c4 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/input_tip.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/key_dark.svg b/redisinsight/ui/src/assets/img/overview/key_dark.svg new file mode 100644 index 0000000000..7c82368959 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/key_dark.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/key_light.svg b/redisinsight/ui/src/assets/img/overview/key_light.svg new file mode 100644 index 0000000000..8d8ef40b67 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/key_light.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/key_tip.svg b/redisinsight/ui/src/assets/img/overview/key_tip.svg new file mode 100644 index 0000000000..107ce1e828 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/key_tip.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/measure_dark.svg b/redisinsight/ui/src/assets/img/overview/measure_dark.svg new file mode 100644 index 0000000000..10bc604084 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/measure_dark.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/measure_light.svg b/redisinsight/ui/src/assets/img/overview/measure_light.svg new file mode 100644 index 0000000000..1c0601f46d --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/measure_light.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/measure_tip.svg b/redisinsight/ui/src/assets/img/overview/measure_tip.svg new file mode 100644 index 0000000000..582d068282 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/measure_tip.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/memory_dark.svg b/redisinsight/ui/src/assets/img/overview/memory_dark.svg new file mode 100644 index 0000000000..214ae755be --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/memory_dark.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/memory_light.svg b/redisinsight/ui/src/assets/img/overview/memory_light.svg new file mode 100644 index 0000000000..3b5473e90b --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/memory_light.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/output_light.svg b/redisinsight/ui/src/assets/img/overview/output_light.svg new file mode 100644 index 0000000000..cf2c0a82c5 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/output_light.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/output_tip.svg b/redisinsight/ui/src/assets/img/overview/output_tip.svg new file mode 100644 index 0000000000..96e9faee6b --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/output_tip.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/time_dark.svg b/redisinsight/ui/src/assets/img/overview/time_dark.svg new file mode 100644 index 0000000000..c9eaac72fc --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/time_dark.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/time_light.svg b/redisinsight/ui/src/assets/img/overview/time_light.svg new file mode 100644 index 0000000000..d3e0da7b88 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/time_light.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/time_tip.svg b/redisinsight/ui/src/assets/img/overview/time_tip.svg new file mode 100644 index 0000000000..6f65f3f34d --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/time_tip.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/user_dark.svg b/redisinsight/ui/src/assets/img/overview/user_dark.svg new file mode 100644 index 0000000000..7eb851e129 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/user_dark.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/user_light.svg b/redisinsight/ui/src/assets/img/overview/user_light.svg new file mode 100644 index 0000000000..91b1644faf --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/user_light.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/overview/user_tip.svg b/redisinsight/ui/src/assets/img/overview/user_tip.svg new file mode 100644 index 0000000000..47ad5a88f6 --- /dev/null +++ b/redisinsight/ui/src/assets/img/overview/user_tip.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/resize-corner.svg b/redisinsight/ui/src/assets/img/resize-corner.svg new file mode 100644 index 0000000000..a924b66608 --- /dev/null +++ b/redisinsight/ui/src/assets/img/resize-corner.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/browser.svg b/redisinsight/ui/src/assets/img/sidebar/browser.svg new file mode 100644 index 0000000000..a6bb2baaea --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/browser.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/browser_active.svg b/redisinsight/ui/src/assets/img/sidebar/browser_active.svg new file mode 100644 index 0000000000..04a857316a --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/browser_active.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/database.svg b/redisinsight/ui/src/assets/img/sidebar/database.svg new file mode 100644 index 0000000000..d18ce47904 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/database.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/database_active.svg b/redisinsight/ui/src/assets/img/sidebar/database_active.svg new file mode 100644 index 0000000000..7f99aaffb3 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/database_active.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/settings.svg b/redisinsight/ui/src/assets/img/sidebar/settings.svg new file mode 100644 index 0000000000..43674b9c67 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/settings.svg @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/settings_active.svg b/redisinsight/ui/src/assets/img/sidebar/settings_active.svg new file mode 100644 index 0000000000..1db14495be --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/settings_active.svg @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/workbench.svg b/redisinsight/ui/src/assets/img/sidebar/workbench.svg new file mode 100644 index 0000000000..e1c0df215a --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/workbench.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/workbench_active.svg b/redisinsight/ui/src/assets/img/sidebar/workbench_active.svg new file mode 100644 index 0000000000..07929f9d11 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/workbench_active.svg @@ -0,0 +1,15 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/welcome_bg_dark.jpg b/redisinsight/ui/src/assets/img/welcome_bg_dark.jpg new file mode 100644 index 0000000000..dbe2cefa5b Binary files /dev/null and b/redisinsight/ui/src/assets/img/welcome_bg_dark.jpg differ diff --git a/redisinsight/ui/src/assets/img/welcome_bg_light.jpg b/redisinsight/ui/src/assets/img/welcome_bg_light.jpg new file mode 100644 index 0000000000..9a8e0158a7 Binary files /dev/null and b/redisinsight/ui/src/assets/img/welcome_bg_light.jpg differ diff --git a/redisinsight/ui/src/assets/img/workbench/RediSearchNotAvailableDark.jpg b/redisinsight/ui/src/assets/img/workbench/RediSearchNotAvailableDark.jpg new file mode 100644 index 0000000000..eedecc3b88 Binary files /dev/null and b/redisinsight/ui/src/assets/img/workbench/RediSearchNotAvailableDark.jpg differ diff --git a/redisinsight/ui/src/assets/img/workbench/RediSearchNotAvailableLight.jpg b/redisinsight/ui/src/assets/img/workbench/RediSearchNotAvailableLight.jpg new file mode 100644 index 0000000000..c67f3de234 Binary files /dev/null and b/redisinsight/ui/src/assets/img/workbench/RediSearchNotAvailableLight.jpg differ diff --git a/redisinsight/ui/src/assets/img/workbench/default_view_dark.svg b/redisinsight/ui/src/assets/img/workbench/default_view_dark.svg new file mode 100644 index 0000000000..e64fb0542e --- /dev/null +++ b/redisinsight/ui/src/assets/img/workbench/default_view_dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/workbench/default_view_light.svg b/redisinsight/ui/src/assets/img/workbench/default_view_light.svg new file mode 100644 index 0000000000..743be20688 --- /dev/null +++ b/redisinsight/ui/src/assets/img/workbench/default_view_light.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/workbench/table_view_icon_dark.svg b/redisinsight/ui/src/assets/img/workbench/table_view_icon_dark.svg new file mode 100644 index 0000000000..9e05fcf50d --- /dev/null +++ b/redisinsight/ui/src/assets/img/workbench/table_view_icon_dark.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/redisinsight/ui/src/assets/img/workbench/table_view_icon_light.svg b/redisinsight/ui/src/assets/img/workbench/table_view_icon_light.svg new file mode 100644 index 0000000000..2b92cfbe79 --- /dev/null +++ b/redisinsight/ui/src/assets/img/workbench/table_view_icon_light.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/redisinsight/ui/src/components/CircularSpinnerPage.tsx b/redisinsight/ui/src/components/CircularSpinnerPage.tsx new file mode 100644 index 0000000000..7c8d2082d0 --- /dev/null +++ b/redisinsight/ui/src/components/CircularSpinnerPage.tsx @@ -0,0 +1,34 @@ +import React, { CSSProperties } from 'react' +// import { CircularProgress } from 'material-ui'; + +const containerStyle = { + width: '100%', + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +} + +const CircularSpinnerPage = (props: { + style?: CSSProperties; + msg?: string; + msgStyle?: CSSProperties; +}) => { + const { style = {}, msg, msgStyle = {} } = props + return ( +
+
+ {/* */} + {msg &&
{msg}
} +
+
+ ) +} + +export default CircularSpinnerPage diff --git a/redisinsight/ui/src/components/ContentEditable.tsx b/redisinsight/ui/src/components/ContentEditable.tsx new file mode 100644 index 0000000000..1526593c18 --- /dev/null +++ b/redisinsight/ui/src/components/ContentEditable.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import ReactContentEditable, { Props } from 'react-contenteditable' + +const useRefCallback = ( + value: ((...args: T) => void) | undefined, + deps?: React.DependencyList +): ((...args: T) => void) => { + const ref = React.useRef(value) + + React.useEffect(() => { + ref.current = value + }, deps ?? [value]) + + return React.useCallback((...args: T) => { + ref.current?.(...args) + }, []) +} + +// remove line break and encode angular brackets +export const parsePastedText = (text: string = '') => + text.replace(/\n/gi, '').replace(//gi, '>') + +export const parseContentEditableChangeHtml = (text: string = '') => text.replace(/ /gi, ' ') + +export const parseMultilineContentEditableChangeHtml = (text: string = '') => + parseContentEditableChangeHtml(text).replace(/
/gi, ' ') + +export const parseContentEditableHtml = (text: string = '') => + text + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + +const onPaste = (e: React.ClipboardEvent) => { + e.preventDefault() + + const clipboardData = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData + const text = clipboardData.getData('text/plain') as string + + document.execCommand('insertText', false, parsePastedText(text)) +} + +export default function ContentEditable({ + ref, + onChange, + onInput, + onBlur, + onKeyPress, + onKeyDown, + onMouseUp, + ...props +}: Props) { + const onChangeRef = useRefCallback(onChange) + const onInputRef = useRefCallback(onInput) + const onBlurRef = useRefCallback(onBlur) + const onKeyPressRef = useRefCallback(onKeyPress) + const onKeyDownRef = useRefCallback(onKeyDown) + const onMouseUpRef = useRefCallback(onMouseUp) + + return ( + + ) +} diff --git a/redisinsight/ui/src/components/action-bar/ActionBar.spec.tsx b/redisinsight/ui/src/components/action-bar/ActionBar.spec.tsx new file mode 100644 index 0000000000..796d6a35a6 --- /dev/null +++ b/redisinsight/ui/src/components/action-bar/ActionBar.spec.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { fireEvent, screen, render } from 'uiSrc/utils/test-utils' +import ActionBar, { Props } from './ActionBar' + +const mockedProps = mock() + +describe('ActionBar', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call "onCloseActionBar"', () => { + const handleClick = jest.fn() + + const renderer = render( + + ) + + expect(renderer).toBeTruthy() + + fireEvent.click(screen.getByTestId('cancel-selecting')) + expect(handleClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/redisinsight/ui/src/components/action-bar/ActionBar.tsx b/redisinsight/ui/src/components/action-bar/ActionBar.tsx new file mode 100644 index 0000000000..8ae5d1683d --- /dev/null +++ b/redisinsight/ui/src/components/action-bar/ActionBar.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui' + +import styles from './styles.module.scss' + +export interface Props { + width: number; + selectionCount: number; + actions: JSX.Element; + onCloseActionBar: () => void; +} + +const ActionBar = ({ + width, + selectionCount, + actions, + onCloseActionBar, +}: Props) => ( +
+ + + {`You selected: ${selectionCount} items`} + + + {actions} + + + onCloseActionBar()} + data-testid="cancel-selecting" + /> + + +
+) + +export default ActionBar diff --git a/redisinsight/ui/src/components/action-bar/styles.module.scss b/redisinsight/ui/src/components/action-bar/styles.module.scss new file mode 100644 index 0000000000..f4875b7271 --- /dev/null +++ b/redisinsight/ui/src/components/action-bar/styles.module.scss @@ -0,0 +1,49 @@ +:global { + .euiPopoverTitle { + text-transform: none !important; + } + + .euiButton { + min-width: 93px !important; + + &:focus { + text-decoration: none !important; + } + } +} + +.container { + position: fixed; + + width: 332px; + height: 50px; + background-color: var(--euiColorLightShade); + border-radius: 20px; + bottom: calc(9vh + 9px); + padding-left: 5px; +} + +.text { + font-size: 12px; +} +.actions { + span, + svg { + font-size: 14px !important; + } + + svg { + width: 14px; + height: 14px; + } +} + +.cross { + :global(.euiButtonIcon) { + margin-left: 15px; + } + svg { + width: 20px; + height: 20px; + } +} diff --git a/redisinsight/ui/src/components/advanced-settings/AdvancedSettings.spec.tsx b/redisinsight/ui/src/components/advanced-settings/AdvancedSettings.spec.tsx new file mode 100644 index 0000000000..a7887533d4 --- /dev/null +++ b/redisinsight/ui/src/components/advanced-settings/AdvancedSettings.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { + render, + screen, + fireEvent, +} from 'uiSrc/utils/test-utils' +import AdvancedSettings from './AdvancedSettings' + +jest.mock('uiSrc/slices/user/user-settings', () => ({ + ...jest.requireActual('uiSrc/slices/user/user-settings'), + userSettingsSelector: jest.fn().mockReturnValue({ + config: { + scanThreshold: 10000 + }, + }), + updateUserConfigSettingsAction: () => jest.fn +})) + +describe('AdvancedSettings', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render keys to scan value', () => { + render() + expect(screen.getByTestId(/keys-to-scan-value/)).toHaveTextContent('10000') + }) + + it('should render keys to scan input after click value', () => { + render() + screen.getByTestId(/keys-to-scan-value/).click() + expect(screen.getByTestId(/keys-to-scan-input/)).toBeInTheDocument() + }) + + it('should change keys to scan input properly', () => { + render() + screen.getByTestId(/keys-to-scan-value/).click() + fireEvent.change( + screen.getByTestId(/keys-to-scan-input/), + { + target: { value: '6900' } + } + ) + expect(screen.getByTestId(/keys-to-scan-input/)).toHaveValue('6900') + }) + + it('should properly apply changes', () => { + render() + + screen.getByTestId(/keys-to-scan-value/).click() + fireEvent.change( + screen.getByTestId(/keys-to-scan-input/), + { + target: { value: '6900' } + } + ) + screen.getByTestId(/apply-btn/).click() + expect(screen.getByTestId(/keys-to-scan-value/)).toHaveTextContent('6900') + }) + + it('should properly decline changes', () => { + render() + screen.getByTestId(/keys-to-scan-value/).click() + + fireEvent.change( + screen.getByTestId(/keys-to-scan-input/), + { + target: { value: '6900' } + } + ) + screen.getByTestId(/cancel-btn/).click() + expect(screen.getByTestId(/keys-to-scan-value/)).toHaveTextContent('10000') + }) +}) diff --git a/redisinsight/ui/src/components/advanced-settings/AdvancedSettings.tsx b/redisinsight/ui/src/components/advanced-settings/AdvancedSettings.tsx new file mode 100644 index 0000000000..90d2e55861 --- /dev/null +++ b/redisinsight/ui/src/components/advanced-settings/AdvancedSettings.tsx @@ -0,0 +1,127 @@ +import React, { ChangeEvent, useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' +import { + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui' + +import { validateCountNumber } from 'uiSrc/utils' +import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { updateUserConfigSettingsAction, userSettingsSelector } from 'uiSrc/slices/user/user-settings' +import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' + +import styles from './styles.module.scss' + +const AdvancedSettings = () => { + const [keysToScan, setKeysToScan] = useState('') + const [keysToScanInitial, setKeysToScanInitial] = useState('') + const [isKeysToScanEditing, setIsKeysToScanEditing] = useState(false) + const [isKeysToScanHovering, setIsKeysToScanHovering] = useState(false) + + const { config } = useSelector(userSettingsSelector) + + const dispatch = useDispatch() + + useEffect(() => { + setKeysToScan(config?.scanThreshold.toString()) + setKeysToScanInitial(config?.scanThreshold.toString()) + }, [config]) + + const handleApplyChanges = () => { + setIsKeysToScanEditing(false) + setIsKeysToScanHovering(false) + + // eslint-disable-next-line no-nested-ternary + const data = keysToScan ? (+keysToScan < SCAN_COUNT_DEFAULT ? SCAN_COUNT_DEFAULT : +keysToScan) : null + + dispatch( + updateUserConfigSettingsAction( + { scanThreshold: data }, + () => {}, + () => setKeysToScan(keysToScanInitial) + ) + ) + } + + const handleDeclineChanges = (event?: React.MouseEvent) => { + event?.stopPropagation() + setKeysToScan(keysToScanInitial) + setIsKeysToScanEditing(false) + setIsKeysToScanHovering(false) + } + + const onChange = ({ currentTarget: { value } }: ChangeEvent) => { + isKeysToScanEditing && setKeysToScan(validateCountNumber(value)) + } + + const appendKeysToScanEditing = () => + (!isKeysToScanEditing ? : '') + + return ( + <> + + + Filter by Key Type or Pattern + + + Filtering by pattern per a large number of keys may decrease performance. Clear + the control to restore the default value. + + + + + Keys to Scan: + + + setIsKeysToScanHovering(true)} + onMouseLeave={() => setIsKeysToScanHovering(false)} + onClick={() => setIsKeysToScanEditing(true)} + grow={false} + component="span" + style={{ paddingBottom: '1px' }} + > + {isKeysToScanEditing || isKeysToScanHovering ? ( + + + + ) : ( + + {keysToScan} + + )} + + + + ) +} + +export default AdvancedSettings diff --git a/redisinsight/ui/src/components/advanced-settings/styles.module.scss b/redisinsight/ui/src/components/advanced-settings/styles.module.scss new file mode 100644 index 0000000000..5abface0c2 --- /dev/null +++ b/redisinsight/ui/src/components/advanced-settings/styles.module.scss @@ -0,0 +1,30 @@ +.keysToScanInput { + height: 31px !important; + font-family: 'Graphik', sans-serif !important; +} + +.keysToScanInputEditing { + height: 33px !important; +} + +.keysToScanWrapper { + height: 40px; + + :global(.euiFormControlLayout--group.euiFormControlLayout--readOnly) { + border: 1px solid var(--controlsBorderColor); + cursor: auto; + } +} + +.inputLabel { + font-weight: 500 !important; +} + +.keysToScanValue { + color: var(--inputTextColor) !important; + font-size: 13px !important; + padding: 0 9px; + line-height: 33px !important; + height: 33px !important; + min-width: 150px; +} diff --git a/redisinsight/ui/src/components/cli/Cli/Cli.spec.tsx b/redisinsight/ui/src/components/cli/Cli/Cli.spec.tsx new file mode 100644 index 0000000000..7d07cd5166 --- /dev/null +++ b/redisinsight/ui/src/components/cli/Cli/Cli.spec.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' +import CLI from './Cli' + +const redisCommandsPath = 'uiSrc/slices/app/redis-commands' + +jest.mock(redisCommandsPath, () => { + const defaultState = jest.requireActual(redisCommandsPath).initialState + return { + ...jest.requireActual(redisCommandsPath), + appRedisCommandsSelector: jest.fn().mockReturnValue({ + ...defaultState, + }), + } +}) + +describe('CLI', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/cli/Cli/Cli.tsx b/redisinsight/ui/src/components/cli/Cli/Cli.tsx new file mode 100644 index 0000000000..ba03471815 --- /dev/null +++ b/redisinsight/ui/src/components/cli/Cli/Cli.tsx @@ -0,0 +1,16 @@ +import React from 'react' + +import CliHeader from 'uiSrc/components/cli/components/cli-header' +import CliBodyWrapper from 'uiSrc/components/cli/components/cli-body' +import styles from './styles.module.scss' + +const CLI = () => ( +
+
+ + +
+
+) + +export default CLI diff --git a/redisinsight/ui/src/components/cli/Cli/index.ts b/redisinsight/ui/src/components/cli/Cli/index.ts new file mode 100644 index 0000000000..9e8a4334c7 --- /dev/null +++ b/redisinsight/ui/src/components/cli/Cli/index.ts @@ -0,0 +1,3 @@ +import Cli from './Cli' + +export default Cli diff --git a/redisinsight/ui/src/components/cli/Cli/styles.module.scss b/redisinsight/ui/src/components/cli/Cli/styles.module.scss new file mode 100644 index 0000000000..d10e657e75 --- /dev/null +++ b/redisinsight/ui/src/components/cli/Cli/styles.module.scss @@ -0,0 +1,22 @@ +@import '@elastic/eui/src/global_styling/mixins/helpers'; +@import '@elastic/eui/src/components/table/mixins'; +@import '@elastic/eui/src/global_styling/index'; + +.container { + height: 100%; + width: 100%; + padding-left: 16px; + padding-right: 16px; +} + +.main { + @include euiScrollBar; + box-sizing: border-box; + height: 100%; + width: 100%; + position: relative; + background-color: var(--euiColorEmptyShade); + border-left: 1px solid var(--euiColorLightShade); + border-right: 1px solid var(--euiColorLightShade); + border-top: 1px solid var(--euiColorLightShade); +} diff --git a/redisinsight/ui/src/components/cli/CliWrapper.spec.tsx b/redisinsight/ui/src/components/cli/CliWrapper.spec.tsx new file mode 100644 index 0000000000..08655bc584 --- /dev/null +++ b/redisinsight/ui/src/components/cli/CliWrapper.spec.tsx @@ -0,0 +1,41 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { clearSearchingCommand, setCliEnteringCommand } from 'uiSrc/slices/cli/cli-settings' +import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' +import CliWrapper from './CliWrapper' + +const redisCommandsPath = 'uiSrc/slices/app/redis-commands' + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock(redisCommandsPath, () => { + const defaultState = jest.requireActual(redisCommandsPath).initialState + const { MOCK_COMMANDS_SPEC, MOCK_COMMANDS_ARRAY } = jest.requireActual('uiSrc/constants') + return { + ...jest.requireActual(redisCommandsPath), + appRedisCommandsSelector: jest.fn().mockReturnValue({ + ...defaultState, + spec: MOCK_COMMANDS_SPEC, + commandsArray: Object.keys(MOCK_COMMANDS_ARRAY).sort() + }), + } +}) + +describe('CliWrapper', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + it('Actions should be called after component will unmount', () => { + const { unmount } = render() + + unmount() + + const expectedActions = [clearSearchingCommand(), setCliEnteringCommand()] + expect(store.getActions().slice(-2)).toEqual(expectedActions) + }) +}) diff --git a/redisinsight/ui/src/components/cli/CliWrapper.tsx b/redisinsight/ui/src/components/cli/CliWrapper.tsx new file mode 100644 index 0000000000..18f5c5c03c --- /dev/null +++ b/redisinsight/ui/src/components/cli/CliWrapper.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +import Cli from './Cli' + +const CliWrapper = () => + +export default CliWrapper diff --git a/redisinsight/ui/src/components/cli/components/cli-body/CliBody/CliBody.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-body/CliBody/CliBody.spec.tsx new file mode 100644 index 0000000000..e712422299 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBody/CliBody.spec.tsx @@ -0,0 +1,331 @@ +import { cloneDeep, last } from 'lodash' +import React from 'react' +import { keys } from '@elastic/eui' +import { instance, mock } from 'ts-mockito' +import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import { clearOutput, updateCliHistoryStorage } from 'uiSrc/utils/cli' +import CLI from 'uiSrc/components/cli/Cli' +import { MOCK_COMMANDS_ARRAY } from 'uiSrc/constants' +import CliBody, { Props } from './CliBody' + +const mockedProps = mock() + +let store: typeof mockedStore +const commandHistory = ['info', 'hello', 'keys *', 'clear'] +const commandsArr = MOCK_COMMANDS_ARRAY +const redisCommandsPath = 'uiSrc/slices/app/redis-commands' +const cliOutputPath = 'uiSrc/slices/cli/cli-output' +const cliCommand = 'cli-command' + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock(cliOutputPath, () => { + const defaultState = jest.requireActual(cliOutputPath).initialState + return { + ...jest.requireActual(cliOutputPath), + setOutputInitialState: jest.fn, + outputSelector: jest.fn().mockReturnValue({ + ...defaultState, + commandHistory, + }), + } +}) + +jest.mock(redisCommandsPath, () => { + const defaultState = jest.requireActual(redisCommandsPath).initialState + const { MOCK_COMMANDS_SPEC, MOCK_COMMANDS_ARRAY } = jest.requireActual('uiSrc/constants') + return { + ...jest.requireActual(redisCommandsPath), + appRedisCommandsSelector: jest.fn().mockReturnValue({ + ...defaultState, + spec: MOCK_COMMANDS_SPEC, + commandsArray: MOCK_COMMANDS_ARRAY, + }), + } +}) + +jest.mock('uiSrc/utils/cli', () => ({ + ...jest.requireActual('uiSrc/utils/cli'), + updateCliHistoryStorage: jest.fn(), + clearOutput: jest.fn(), +})) + +describe('CliBody', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + it('Input should render without error', () => { + render() + + const cliInput = screen.queryByTestId(cliCommand) + + expect(cliInput).toBeInTheDocument() + }) + + it('Input should not render with error', () => { + render() + + const cliInput = screen.queryByTestId(cliCommand) + + expect(cliInput).toBeNull() + }) + + describe('CLI input special commands', () => { + it('"clear" command should call "setOutputInitialState"', () => { + const onSubmitMock = jest.fn() + const setCommandMock = jest.fn() + + const command = 'clear' + + render( + + ) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'Enter', + }) + + expect(clearOutput).toBeCalled() + expect(updateCliHistoryStorage).toBeCalledWith(command, expect.any(Function)) + + expect(setCommandMock).toBeCalledWith('') + expect(onSubmitMock).not.toBeCalled() + }) + }) + + describe('CLI input keyboard cases', () => { + it('"Enter" keydown should call "onSubmit"', () => { + const command = 'info' + const onSubmitMock = jest.fn() + + render() + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'Enter', + }) + + expect(updateCliHistoryStorage).toBeCalledWith(command, expect.any(Function)) + expect(onSubmitMock).toBeCalled() + }) + + it('"Ctrl+l" hot key for Windows OS should call "setOutputInitialState"', () => { + const onSubmitMock = jest.fn() + const setCommandMock = jest.fn() + render( + + ) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'l', + ctrlKey: true, + }) + + expect(clearOutput).toBeCalled() + + expect(setCommandMock).toBeCalledWith('') + expect(onSubmitMock).not.toBeCalled() + }) + + it('"Command+k" hot key for MacOS should call "setOutputInitialState"', () => { + const onSubmitMock = jest.fn() + const setCommandMock = jest.fn() + render( + + ) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'k', + metaKey: true, + }) + + expect(clearOutput).toBeCalled() + + expect(setCommandMock).toBeCalledWith('') + expect(onSubmitMock).not.toBeCalled() + }) + + it('"ArrowUp" should call "setCommand" with commands from history', () => { + const onSubmitMock = jest.fn() + const setCommandMock = jest.fn() + render( + + ) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'ArrowUp', + }) + + expect(setCommandMock).toBeCalledWith(commandHistory[0]) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'ArrowUp', + }) + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'ArrowUp', + }) + + expect(setCommandMock).toBeCalledWith(commandHistory[2]) + + expect(onSubmitMock).not.toBeCalled() + }) + + it('"ArrowDown" should call "setCommand" with commands from history', () => { + const onSubmitMock = jest.fn() + const setCommandMock = jest.fn() + render( + + ) + + for (let index = 0; index < 3; index++) { + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'ArrowUp', + }) + } + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'ArrowDown', + }) + + expect(setCommandMock).toBeCalledWith(commandHistory[2]) + + for (let index = 0; index < 3; index++) { + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'ArrowDown', + }) + } + + expect(setCommandMock).toBeCalledWith('') + expect(setCommandMock).toBeCalledTimes(6) + + for (let index = 0; index < 2; index++) { + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: 'ArrowDown', + }) + } + + expect(setCommandMock).toBeCalledTimes(6) + + expect(onSubmitMock).not.toBeCalled() + }) + + it('"Esc" key should focus ', () => { + render() + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: keys.ESCAPE, + }) + + expect(screen.getByTestId('collapse-cli')).toHaveFocus() + }) + + it('"Tab" with command="" should setCommand first command from constants/commands ', () => { + const onSubmitMock = jest.fn() + const setCommandMock = jest.fn() + render( + + ) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: keys.TAB, + }) + + expect(setCommandMock).toBeCalledWith(commandsArr[0]) + + expect(onSubmitMock).not.toBeCalled() + }) + + // eslint-disable-next-line max-len + it('"Tab" with command="g" should setCommand first command starts with "g" from constants/commands ', () => { + const command = 'g' + const onSubmitMock = jest.fn() + const setCommandMock = jest.fn() + render( + + ) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: keys.TAB, + }) + + expect(setCommandMock).toBeCalledWith( + commandsArr.filter((cmd: string) => cmd.startsWith(command.toUpperCase()))[0] + ) + + expect(onSubmitMock).not.toBeCalled() + }) + + // eslint-disable-next-line max-len + it('"Shift+Tab" with command="g" should setCommand last command starts with "g" from constants/commands ', () => { + const command = 'g' + const onSubmitMock = jest.fn() + const setCommandMock = jest.fn() + render( + + ) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: keys.TAB, + shiftKey: true, + }) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: keys.TAB, + }) + + fireEvent.keyDown(screen.getByTestId(cliCommand), { + key: keys.TAB, + shiftKey: true, + }) + + expect(setCommandMock).toBeCalledWith( + last(commandsArr.filter((cmd: string) => cmd.startsWith(command.toUpperCase()))) + ) + + expect(onSubmitMock).not.toBeCalled() + }) + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-body/CliBody/CliBody.tsx b/redisinsight/ui/src/components/cli/components/cli-body/CliBody/CliBody.tsx new file mode 100644 index 0000000000..4317d9d85e --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBody/CliBody.tsx @@ -0,0 +1,253 @@ +import React, { Ref, useEffect, useRef, useState } from 'react' +import { EuiFlexGroup, EuiFlexItem, keys } from '@elastic/eui' +import { useDispatch, useSelector } from 'react-redux' + +import { Nullable } from 'uiSrc/utils' +import { isModifiedEvent } from 'uiSrc/services' +import { ClearCommand } from 'uiSrc/constants/cliOutput' +import { outputSelector } from 'uiSrc/slices/cli/cli-output' +import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' +import CliInputWrapper from 'uiSrc/components/cli/components/cli-input' +import { clearOutput, updateCliHistoryStorage } from 'uiSrc/utils/cli' +import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' + +import styles from './styles.module.scss' + +export interface Props { + data: (string | JSX.Element)[]; + command: string; + error: string; + setCommand: (command: string) => void; + onSubmit: () => void; +} + +const commandTabPosInit = 0 +const commandHistoryPosInit = -1 +const CliBody = (props: Props) => { + const { data, command = '', error, setCommand, onSubmit } = props + + const [inputEl, setInputEl] = useState>(null) + const [commandHistory, setCommandHistory] = useState([]) + const [commandHistoryPos, setCommandHistoryPos] = useState(commandHistoryPosInit) + const [commandTabPos, setCommandTabPos] = useState(commandTabPosInit) + const [wordsTyped, setWordsTyped] = useState(0) + const [matchingCmds, setMatchingCmds] = useState([]) + const { loading: settingsLoading } = useSelector(cliSettingsSelector) + const { loading, commandHistory: commandHistoryStore } = useSelector(outputSelector) + const { commandsArray } = useSelector(appRedisCommandsSelector) + + const scrollDivRef: Ref = useRef(null) + const dispatch = useDispatch() + + useEffect(() => { + inputEl?.focus() + scrollDivRef?.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'end', + }) + }, [command, data, inputEl, scrollDivRef]) + + useEffect(() => { + setCommandHistory(commandHistoryStore) + }, [commandHistoryStore]) + + useEffect(() => { + if (command) { + setWordsTyped( + command.trim().match(/(?:'[^']*'|[^\s'"]|"[^"]*"|\[[^\]]*\])+/g)?.length ?? wordsTyped + ) + } + }, [command]) + + const onClearOutput = (event: React.KeyboardEvent) => { + event.preventDefault() + + clearOutput(dispatch) + setCommand('') + } + + const onKeyDownEnter = (commandLine: string, event: React.KeyboardEvent) => { + event.preventDefault() + + setWordsTyped(0) + setCommandHistoryPos(commandHistoryPosInit) + updateCliHistoryStorage(commandLine, dispatch) + + if (commandLine === ClearCommand) { + onClearOutput(event) + return + } + + onSubmit() + } + + const onKeyDownArrowUp = (event: React.KeyboardEvent) => { + event.preventDefault() + const newPos = commandHistoryPos + 1 + if (newPos >= commandHistory.length) { + return + } + + setCommandFromHistory(newPos) + } + + const onKeyDownArrowDown = (event: React.KeyboardEvent) => { + const newPos = commandHistoryPos - 1 + + if (commandHistoryPos === commandHistoryPosInit) { + event.preventDefault() + return + } + + setCommandFromHistory(newPos) + } + + const onKeyDownTab = (event: React.KeyboardEvent, commandLine: string) => { + event.preventDefault() + + const nextPos = commandTabPos === matchingCmds.length - 1 ? commandTabPosInit : commandTabPos + 1 + let matchingCmdsCurrent = matchingCmds + + if (commandTabPos === commandTabPosInit) { + matchingCmdsCurrent = updateMatchingCmds(commandLine) + } + + if (matchingCmdsCurrent.length > 1) { + setCommand(matchingCmdsCurrent[nextPos]) + setCommandTabPos(nextPos) + } + } + + const onKeyDownShiftTab = (event: React.KeyboardEvent) => { + event.preventDefault() + + let matchingCmdsCurrent = matchingCmds + + if (commandTabPos === commandTabPosInit) { + matchingCmdsCurrent = updateMatchingCmds(command) + } + + const nextPos = commandTabPos ? commandTabPos - 1 : matchingCmdsCurrent.length - 1 + + if (!matchingCmdsCurrent.length) { + return + } + + if (matchingCmdsCurrent.length > 1) { + setCommand(matchingCmdsCurrent[nextPos]) + setCommandTabPos(nextPos) + } + } + + const onKeyEsc = () => { + document.getElementById('collapse-cli')?.focus() + } + + const onKeyDown = (event: React.KeyboardEvent) => { + const commandLine = command?.trim() + + const isModifierKey = isModifiedEvent(event) + + if (event.shiftKey && event.key === keys.TAB) { + onKeyDownShiftTab(event) + return + } + + if (event.key === keys.TAB) { + onKeyDownTab(event, commandLine) + return + } + + // reset command tab position + if (!event.shiftKey || (event.shiftKey && event.key !== 'Shift')) { + setCommandTabPos(commandTabPosInit) + } + + if (event.key === keys.ENTER) { + onKeyDownEnter(commandLine, event) + return + } + + if (event.key === keys.ARROW_UP && !isModifierKey) { + onKeyDownArrowUp(event) + return + } + + if (event.key === keys.ARROW_DOWN && !isModifierKey) { + onKeyDownArrowDown(event) + return + } + + if (event.key === keys.ESCAPE) { + onKeyEsc() + return + } + + if ((event.metaKey && event.key === 'k') || (event.ctrlKey && event.key === 'l')) { + onClearOutput(event) + } + } + + const updateMatchingCmds = (command: string = '') => { + const matchingCmdsCurrent = [ + command, + ...commandsArray.filter((cmd: string) => cmd.startsWith(command.toUpperCase())), + ] + + setMatchingCmds(matchingCmdsCurrent) + + return matchingCmdsCurrent + } + + const setCommandFromHistory = (newPos: number) => { + const newCommand = commandHistory[newPos] ?? '' + + setCommand(newCommand) + setCommandHistoryPos(newPos) + + setTimeout(() => { + inputEl?.focus() + }) + } + + const onMouseUpOutput = () => { + if (!window.getSelection()?.toString()) { + inputEl?.focus() + document.execCommand('selectAll', false) + document.getSelection()?.collapseToEnd() + } + } + + return ( +
+ + +
{data}
+ {!error && !(loading || settingsLoading) ? ( + + + + ) : ( + !error && Executing command... + )} +
+ + +
+ ) +} + +export default CliBody diff --git a/redisinsight/ui/src/components/cli/components/cli-body/CliBody/index.ts b/redisinsight/ui/src/components/cli/components/cli-body/CliBody/index.ts new file mode 100644 index 0000000000..3399a3a9bc --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBody/index.ts @@ -0,0 +1,3 @@ +import CliBody from './CliBody' + +export default CliBody diff --git a/redisinsight/ui/src/components/cli/components/cli-body/CliBody/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-body/CliBody/styles.module.scss new file mode 100644 index 0000000000..168b934237 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBody/styles.module.scss @@ -0,0 +1,56 @@ +@import '@elastic/eui/src/global_styling/mixins/helpers'; +@import '@elastic/eui/src/components/table/mixins'; +@import '@elastic/eui/src/global_styling/index'; + +.section { + position: absolute; + width: 100%; + height: calc(100% - 34px); + display: flex; +} + +.container { + @include euiScrollBar; + flex: auto; + border-top: inherit; + padding: 9px 18px; + height: 100%; + word-break: break-all; + + font: normal normal normal 14px/17px Inconsolata; + text-align: left; + letter-spacing: 0; + color: var(--textColorShade); + + border-top: 1px solid var(--euiColorLightShade); + border-right: 1px solid var(--euiColorLightShade); + + z-index: 10; + + overflow-y: auto; + overflow-x: hidden; +} + +.title { + padding-left: 18px; +} + +.output { + white-space: pre-wrap; +} + +.input { + padding-bottom: 7px; +} + +:global(.cli-output-response-success) { + color: var(--cliOutputResponseColor) !important; +} + +:global(.cli-output-response-fail) { + color: var(--cliOutputResponseFailColor) !important; +} + +:global(.cli-command-wrapper) { + font: normal normal bold 14px/15px Inconsolata !important; +} diff --git a/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.spec.tsx new file mode 100644 index 0000000000..47dd77e029 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.spec.tsx @@ -0,0 +1,164 @@ +import React from 'react' +import { cloneDeep, first } from 'lodash' +import { instance, mock } from 'ts-mockito' +import { + cleanup, + fireEvent, + mockedStore, + render, + screen, + clearStoreActions, +} from 'uiSrc/utils/test-utils' + +import { + concatToOutput, + processUnsupportedCommand, + sendCliClusterCommandAction, +} from 'uiSrc/slices/cli/cli-output' +import { BrowserStorageItem } from 'uiSrc/constants' +import { InitOutputText } from 'uiSrc/constants/cliOutput' +import { processCliClient } from 'uiSrc/slices/cli/cli-settings' +import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { sessionStorageService } from 'uiSrc/services' + +import CliBodyWrapper, { Props } from './CliBodyWrapper' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/services', () => ({ + ...jest.requireActual('uiSrc/services'), + sessionStorageService: { + set: jest.fn(), + get: jest.fn(), + }, +})) + +jest.mock('uiSrc/slices/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + id: '123', + connectionType: 'STANDALONE', + }), +})) + +jest.mock('uiSrc/slices/cli/cli-output', () => ({ + ...jest.requireActual('uiSrc/slices/cli/cli-output'), + sendCliClusterCommandAction: jest.fn(), + processUnsupportedCommand: jest.fn(), + updateCliCommandHistory: jest.fn, +})) + +jest.mock('uiSrc/utils/cli', () => ({ + ...jest.requireActual('uiSrc/utils/cli'), + updateCliHistoryStorage: jest.fn(), + clearOutput: jest.fn(), + cliParseTextResponse: jest.fn(), + cliParseTextResponseWithOffset: jest.fn(), +})) + +const unsupportedCommands = ['sync', 'subscription'] +const cliCommandTestId = 'cli-command' + +jest.mock('uiSrc/slices/cli/cli-settings', () => ({ + ...jest.requireActual('uiSrc/slices/cli/cli-settings'), + cliSettingsSelector: jest.fn().mockReturnValue({ + unsupportedCommands, + matchedCommand: 'get', + isEnteringCommand: true, + isShowHelper: true, + }), +})) + +describe('CliBodyWrapper', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should SessionStorage be called', () => { + const mockUuid = 'test-uuid' + sessionStorageService.get = jest.fn().mockReturnValue(mockUuid) + + render() + + expect(sessionStorageService.get).toBeCalledWith(BrowserStorageItem.cliClientUuid) + }) + + it('should render with SessionStorage', () => { + render() + + const expectedActions = [concatToOutput(InitOutputText('', 0)), processCliClient()] + expect(clearStoreActions(store.getActions().slice(0, expectedActions.length))).toEqual( + clearStoreActions(expectedActions) + ) + }) + + it('"onSubmit" should be called after keyDown Enter', () => { + render() + + fireEvent.keyDown(screen.getByTestId(cliCommandTestId), { + key: 'Enter', + }) + + const expectedActions = [concatToOutput(InitOutputText('', 0)), processCliClient()] + + expect(clearStoreActions(store.getActions().slice(0, expectedActions.length))).toEqual( + clearStoreActions(expectedActions) + ) + }) + + it('CliHelper should be opened by default', () => { + render() + + expect(screen.getByTestId('cli-helper')).toBeInTheDocument() + }) + + // It's not possible to simulate events on contenteditable with testing-react-library, + // or any testing library that uses js - dom, because of a limitation on js - dom itself. + // https://github.com/testing-library/dom-testing-library/pull/235 + it.skip('"onSubmit" should check unsupported commands', () => { + const processUnsupportedCommandMock = jest.fn() + + processUnsupportedCommand.mockImplementation(() => processUnsupportedCommandMock) + + render() + + // Act + fireEvent.change(screen.getByTestId(cliCommandTestId), { + target: { value: first(unsupportedCommands) }, + }) + + // Act + fireEvent.keyDown(screen.getByTestId(cliCommandTestId), { + key: 'Enter', + }) + + expect(processUnsupportedCommandMock).toBeCalled() + }) + + it('"onSubmit" for Cluster connection should call "sendCliClusterCommandAction"', () => { + connectedInstanceSelector.mockImplementation(() => ({ + id: '123', + connectionType: 'CLUSTER', + })) + + const sendCliClusterActionMock = jest.fn() + + sendCliClusterCommandAction.mockImplementation(() => sendCliClusterActionMock) + + render() + + // Act + fireEvent.keyDown(screen.getByTestId(cliCommandTestId), { + key: 'Enter', + }) + + expect(sendCliClusterActionMock).toBeCalled() + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx new file mode 100644 index 0000000000..37cc792bb7 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx @@ -0,0 +1,155 @@ +import { EuiTextColor } from '@elastic/eui' +import { isEmpty } from 'lodash' +import { decode } from 'html-entities' +import React, { useEffect, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { useHotkeys } from 'react-hotkeys-hook' +import { useParams } from 'react-router-dom' + +import { + cliSettingsSelector, + createCliClientAction, + updateCliClientAction, + setCliEnteringCommand, + clearSearchingCommand, +} from 'uiSrc/slices/cli/cli-settings' +import { + concatToOutput, + outputSelector, + sendCliCommandAction, + sendCliClusterCommandAction, + processUnsupportedCommand, +} from 'uiSrc/slices/cli/cli-output' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { BrowserStorageItem } from 'uiSrc/constants' +import { ConnectionType } from 'uiSrc/slices/interfaces' +import { sessionStorageService } from 'uiSrc/services' +import { ClusterNodeRole } from 'uiSrc/slices/interfaces/cli' +import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { checkUnsupportedCommand, clearOutput } from 'uiSrc/utils/cli' +import { InitOutputText, ConnectionSuccessOutputText } from 'uiSrc/constants/cliOutput' +import { SendClusterCommandDto } from 'apiSrc/modules/cli/dto/cli.dto' + +import CliBody from './CliBody' +import styles from './CliBody/styles.module.scss' +import CliHelperWrapper from '../cli-helper' + +const CliBodyWrapper = () => { + const cliClientUuid = sessionStorageService.get(BrowserStorageItem.cliClientUuid) ?? '' + + const [command, setCommand] = useState('') + + const dispatch = useDispatch() + const { instanceId = '' } = useParams<{ instanceId: string }>() + const { data = [] } = useSelector(outputSelector) + const { + errorClient: error, + unsupportedCommands, + isShowHelper, + isEnteringCommand, + isSearching, + matchedCommand + } = useSelector(cliSettingsSelector) + const { host, port, connectionType } = useSelector(connectedInstanceSelector) + + useEffect(() => { + if (isEmpty(data) || error) { + dispatch(concatToOutput(InitOutputText(host, port))) + } + + if (cliClientUuid) { + dispatch(updateCliClientAction(cliClientUuid, onSuccess, onFail)) + return + } + + dispatch(createCliClientAction(onSuccess, onFail)) + }, []) + + useEffect(() => { + if (!isEnteringCommand) { + dispatch(setCliEnteringCommand()) + } + if (isSearching && matchedCommand) { + dispatch(clearSearchingCommand()) + } + }, [command]) + + const handleClearOutput = () => { + clearOutput(dispatch) + } + + const refHotkeys = useHotkeys('command+k,ctrl+l', handleClearOutput) + + const onSuccess = () => { + if (isEmpty(data) || error) { + dispatch(concatToOutput(ConnectionSuccessOutputText)) + } + } + + const onFail = (message: string) => { + dispatch( + concatToOutput([ + '\n', + + {message} + , + '\n\n', + ]) + ) + } + + const handleSubmit = () => { + const commandLine = decode(command).trim() + const unsupportedCommand = checkUnsupportedCommand(unsupportedCommands, commandLine) + + if (unsupportedCommand) { + dispatch(processUnsupportedCommand(commandLine, unsupportedCommand, resetCommand)) + return + } + + sendCommand(commandLine) + } + + const sendCommand = (command: string) => { + sendEventTelemetry({ + event: TelemetryEvent.CLI_COMMAND_SUBMITTED, + eventData: { + databaseId: instanceId + } + }) + if (connectionType !== ConnectionType.Cluster) { + dispatch(sendCliCommandAction(command, resetCommand)) + return + } + + const options: SendClusterCommandDto = { + command, + nodeOptions: { + host, + port, + enableRedirection: true, + }, + role: ClusterNodeRole.All, + } + dispatch(sendCliClusterCommandAction(command, options, resetCommand)) + } + + const resetCommand = () => { + setCommand('') + } + + return ( +
+ + {isShowHelper && } +
+ ) +} + +export default CliBodyWrapper diff --git a/redisinsight/ui/src/components/cli/components/cli-body/index.ts b/redisinsight/ui/src/components/cli/components/cli-body/index.ts new file mode 100644 index 0000000000..21f79f41cd --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-body/index.ts @@ -0,0 +1,3 @@ +import CliBodyWrapper from './CliBodyWrapper' + +export default CliBodyWrapper diff --git a/redisinsight/ui/src/components/cli/components/cli-command-info/CliCommandInfo.tsx b/redisinsight/ui/src/components/cli/components/cli-command-info/CliCommandInfo.tsx new file mode 100644 index 0000000000..be61529517 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-command-info/CliCommandInfo.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { EuiBadge, EuiText, EuiTextColor } from '@elastic/eui' +import { GroupBadge } from 'uiSrc/components' +import { CommandGroup } from 'uiSrc/constants' + +import styles from './styles.module.scss' + +export interface Props { + args: string; + group: CommandGroup | string; + complexity: string; +} + +const CliCommandInfo = (props: Props) => { + const { args = '', group = CommandGroup.Generic, complexity = '' } = props + + return ( +
+ + + {args} + + {complexity && ( + + + {complexity} + + + )} +
+ ) +} + +export default CliCommandInfo diff --git a/redisinsight/ui/src/components/cli/components/cli-command-info/index.ts b/redisinsight/ui/src/components/cli/components/cli-command-info/index.ts new file mode 100644 index 0000000000..0e9667fe8b --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-command-info/index.ts @@ -0,0 +1,3 @@ +import CliCommandInfo from './CliCommandInfo' + +export default CliCommandInfo diff --git a/redisinsight/ui/src/components/cli/components/cli-command-info/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-command-info/styles.module.scss new file mode 100644 index 0000000000..8684a747ef --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-command-info/styles.module.scss @@ -0,0 +1,17 @@ +.container { + font: normal normal 500 14px/21px Graphik, sans-serif !important; +} + +.title { + padding: 0 7px; + display: inline; + vertical-align: text-top; +} + +.badge { + background-color: var(--badgeBackgroundColor) !important; +} + +.groupBadge { + background-color: var(--commandGroupBadgeColor) !important; +} diff --git a/redisinsight/ui/src/components/cli/components/cli-header-minimized/CliHeaderMinimized.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-header-minimized/CliHeaderMinimized.spec.tsx new file mode 100644 index 0000000000..7be5f7f5a9 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-header-minimized/CliHeaderMinimized.spec.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { instance, mock } from 'ts-mockito' + +import { toggleCli, clearSearchingCommand } from 'uiSrc/slices/cli/cli-settings' +import { fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import CliHeaderMinimized, { Props } from './CliHeaderMinimized' + +const mockedProps = mock() + +describe('CliHeaderMinimized', () => { + it('should render', () => { + expect( + render() + ).toBeTruthy() + }) + + it('should "toggleCli" & "clearSearchingCommand" actions be called after click "expand-cli" button', () => { + const store = cloneDeep(mockedStore) + + render() + fireEvent.click(screen.getByTestId('expand-cli')) + + const expectedActions = [toggleCli(), clearSearchingCommand()] + expect(store.getActions()).toEqual(expectedActions) + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-header-minimized/CliHeaderMinimized.tsx b/redisinsight/ui/src/components/cli/components/cli-header-minimized/CliHeaderMinimized.tsx new file mode 100644 index 0000000000..3a475d3714 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-header-minimized/CliHeaderMinimized.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiText, EuiToolTip } from '@elastic/eui' +import { useDispatch } from 'react-redux' +import { useParams } from 'react-router-dom' + +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { toggleCli, clearSearchingCommand } from 'uiSrc/slices/cli/cli-settings' + +import styles from '../cli-header/styles.module.scss' + +const CliHeaderMinimized = () => { + const { instanceId = '' } = useParams<{ instanceId: string }>() + const dispatch = useDispatch() + + const handleExpandCli = () => { + sendEventTelemetry({ + event: TelemetryEvent.CLI_OPENED, + eventData: { + databaseId: instanceId + } + }) + dispatch(toggleCli()) + dispatch(clearSearchingCommand()) + } + + return ( +
+ + + CLI + + + + + {}} + /> + + + +
+ ) +} + +export default CliHeaderMinimized diff --git a/redisinsight/ui/src/components/cli/components/cli-header-minimized/index.ts b/redisinsight/ui/src/components/cli/components/cli-header-minimized/index.ts new file mode 100644 index 0000000000..7e207404d1 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-header-minimized/index.ts @@ -0,0 +1,3 @@ +import CliHeaderMinimized from './CliHeaderMinimized' + +export default CliHeaderMinimized diff --git a/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.spec.tsx new file mode 100644 index 0000000000..b03f77b94e --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.spec.tsx @@ -0,0 +1,118 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { instance, mock } from 'ts-mockito' + +import { + cleanup, + fireEvent, + sessionStorageMock, + mockedStore, + render, + screen, + waitFor, +} from 'uiSrc/utils/test-utils' +import { BrowserStorageItem } from 'uiSrc/constants' +import { processCliClient, toggleCli, toggleCliHelper } from 'uiSrc/slices/cli/cli-settings' +import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { sessionStorageService } from 'uiSrc/services' +import CliHeader, { Props } from './CliHeader' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/slices/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + host: 'localhost', + port: 6379, + }), +})) + +jest.mock('uiSrc/services', () => ({ + ...jest.requireActual('uiSrc/services'), + sessionStorageService: { + set: jest.fn(), + get: jest.fn(), + }, +})) + +describe('CliHeader', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should "toggleCli" action be called after click "collapse-cli" button', () => { + render() + fireEvent.click(screen.getByTestId('collapse-cli')) + + const expectedActions = [toggleCli()] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should "toggleCli" action be called after click "collapse-cli" button', async () => { + const mockUuid = 'test-uuid' + sessionStorageMock.getItem = jest.fn().mockReturnValue(mockUuid) + + render() + + await waitFor(() => { + fireEvent.click(screen.getByTestId('collapse-cli')) + }) + + const expectedActions = [toggleCli()] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should "toggleCliHelper" action be called after click "collapse-cli-helper" button', async () => { + const mockUuid = 'test-uuid' + sessionStorageMock.getItem = jest.fn().mockReturnValue(mockUuid) + + render() + + await waitFor(() => { + fireEvent.click(screen.getByTestId('collapse-cli-helper')) + }) + + const expectedActions = [toggleCliHelper()] + expect(store.getActions().slice(0, expectedActions.length)).toEqual(expectedActions) + }) + + it('should "processCliClient" action be called after unmount with mocked sessionStorage item ', () => { + const mockUuid = 'test-uuid' + sessionStorageService.get = jest.fn().mockReturnValue(mockUuid) + + const { unmount } = render() + + unmount() + + expect(sessionStorageService.get).toBeCalledWith(BrowserStorageItem.cliClientUuid) + + const expectedActions = [processCliClient()] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('Cli endpoint should be equal connected Instance host:port', () => { + const host = 'localhost' + const port = 6379 + const endpoint = `${host}:${port}` + const mockEndpoint = `cli-endpoint-${endpoint}` + + connectedInstanceSelector.mockImplementation(() => ({ + host, + port, + })) + + const { queryByTestId } = render() + + const endpointEl = queryByTestId(mockEndpoint) + + expect(endpointEl).toBeInTheDocument() + expect(endpointEl).toHaveTextContent(endpoint) + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.tsx b/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.tsx new file mode 100644 index 0000000000..67b702431f --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-header/CliHeader.tsx @@ -0,0 +1,144 @@ +import React, { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import cx from 'classnames' +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiText, + EuiToolTip, + EuiTextColor, +} from '@elastic/eui' + +import { + cliSettingsSelector, + deleteCliClientAction, + toggleCli, + toggleCliHelper, +} from 'uiSrc/slices/cli/cli-settings' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { BrowserStorageItem } from 'uiSrc/constants' +import { sessionStorageService } from 'uiSrc/services' +import { connectedInstanceSelector } from 'uiSrc/slices/instances' + +import styles from './styles.module.scss' + +const CliHeader = () => { + const dispatch = useDispatch() + + const { instanceId = '' } = useParams<{ instanceId: string }>() + + const { isShowHelper } = useSelector(cliSettingsSelector) + const { host, port } = useSelector(connectedInstanceSelector) + const endpoint = `${host}:${port}` + + const removeCliClient = () => { + const cliClientUuid = sessionStorageService.get(BrowserStorageItem.cliClientUuid) ?? '' + + cliClientUuid && dispatch(deleteCliClientAction(instanceId, cliClientUuid)) + } + + useEffect(() => { + window.addEventListener('beforeunload', removeCliClient, false) + return () => { + removeCliClient() + window.removeEventListener('beforeunload', removeCliClient, false) + } + }, []) + + const handleCollapseCli = () => { + sendEventTelemetry({ + event: TelemetryEvent.CLI_HIDDEN, + eventData: { + databaseId: instanceId + } + }) + dispatch(toggleCli()) + } + + const handleCollapseCliHelper = (event: React.MouseEvent) => { + event.stopPropagation() + sendEventTelemetry({ + event: isShowHelper ? TelemetryEvent.COMMAND_HELPER_COLLAPSED : TelemetryEvent.COMMAND_HELPER_EXPANDED, + eventData: { + databaseId: instanceId + } + }) + dispatch(toggleCliHelper()) + } + + return ( +
+ + + CLI + + + + + e.stopPropagation()}> + Endpoint: + + {endpoint} + + + + + + + + + + + + {}} + /> + + + +
+ ) +} + +export default CliHeader diff --git a/redisinsight/ui/src/components/cli/components/cli-header/index.ts b/redisinsight/ui/src/components/cli/components/cli-header/index.ts new file mode 100644 index 0000000000..6d9064cd6f --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-header/index.ts @@ -0,0 +1,3 @@ +import CliHeader from './CliHeader' + +export default CliHeader diff --git a/redisinsight/ui/src/components/cli/components/cli-header/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-header/styles.module.scss new file mode 100644 index 0000000000..4a147e986d --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-header/styles.module.scss @@ -0,0 +1,45 @@ +.container, +.containerMinimized { + height: 34px; + line-height: 34px; + width: 100%; + overflow: hidden; + background-color: var(--browserTableRowEven); + + padding-left: 18px; + padding-right: 18px; + z-index: 10; +} + +.containerMinimized { + margin-left: 16px; + cursor: pointer; + width: calc(100% - 32px); + border: 1px solid var(--euiColorLightShade); +} + +.icon { + margin-left: 5px; +} + +.iconHelper svg { + width: 24px; + height: 24px; +} + +.endpointContainer { + cursor: default; + font: normal normal normal 12px/15px Graphik, sans-serif !important; + max-width: 210px; + display: inline-flex; + padding-right: 10px; +} + +.endpoint { + font: normal normal normal 12px/15px Graphik, sans-serif !important; + display: inline-block !important; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding-left: 5px; +} diff --git a/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/CliHelper.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/CliHelper.spec.tsx new file mode 100644 index 0000000000..1ba02539fb --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/CliHelper.spec.tsx @@ -0,0 +1,150 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { cleanup, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import CliHelper, { Props } from './CliHelper' + +const mockedProps = mock() +let store: typeof mockedStore + +const redisCommandsPath = 'uiSrc/slices/app/redis-commands' + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock(redisCommandsPath, () => { + const defaultState = jest.requireActual(redisCommandsPath).initialState + const { MOCK_COMMANDS_SPEC, MOCK_COMMANDS_ARRAY } = jest.requireActual('uiSrc/constants') + return { + ...jest.requireActual(redisCommandsPath), + appRedisCommandsSelector: jest.fn().mockReturnValue({ + ...defaultState, + spec: MOCK_COMMANDS_SPEC, + commandsArray: MOCK_COMMANDS_ARRAY + }), + } +}) + +const commandLine = 'get' +const mockedSearchedCommands = ['HSET', 'SET'] + +describe('CliHelper', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('Cli Helper should be in the Document', () => { + render() + + const cliHelper = screen.queryByTestId('cli-helper') + + expect(cliHelper).toBeInTheDocument() + }) + + it('Default text component should be in the Document by default', () => { + render() + + const cliHelperDefault = screen.queryByTestId('cli-helper-default') + + expect(cliHelperDefault).toBeInTheDocument() + }) + + it('Default text component should not be in the Document when Command is matched', () => { + const { queryByTestId } = render( + + ) + + const cliHelperDefault = queryByTestId('cli-helper-default') + + expect(cliHelperDefault).not.toBeInTheDocument() + }) + + it('Cli Helper search should be in the Document', () => { + render() + + const cliHelperSearch = screen.queryByTestId('cli-helper-search') + + expect(cliHelperSearch).toBeInTheDocument() + }) + + it('Title text component should be in the Document when Command is matched', () => { + const { queryByTestId } = render( + + ) + + const cliHelperTitle = queryByTestId('cli-helper-title') + + expect(cliHelperTitle).toBeInTheDocument() + }) + + it('Summary text component should be in the Document when Command is matched and summary exists', () => { + const { queryByTestId } = render( + + ) + + const cliHelperTitle = queryByTestId('cli-helper-summary') + + expect(cliHelperTitle).toBeInTheDocument() + }) + + it('Complexity badge text component should be in the Document when Command is matched and complexity exists', () => { + const { queryByTestId } = render( + + ) + + const cliHelperTitle = queryByTestId('cli-helper-complexity-short') + + expect(cliHelperTitle).toBeInTheDocument() + }) + + it('Complexity text component should be in the Document when Command is matched and complexity exists', () => { + const { queryByTestId } = render( + + ) + + const cliHelperTitle = queryByTestId('cli-helper-complexity') + + expect(cliHelperTitle).toBeInTheDocument() + }) + + it('Complexity text component should not be in the Document when Command is matched and complexity exists and ComplexityShort detected', () => { + const { queryByTestId } = render( + + ) + + const cliHelperTitle = queryByTestId('cli-helper-complexity') + + expect(cliHelperTitle).not.toBeInTheDocument() + }) + + it('Since text component should be in the Document when Command is matched and since exists', () => { + const { queryByTestId } = render( + + ) + + const cliHelperTitle = queryByTestId('cli-helper-since') + + expect(cliHelperTitle).toBeInTheDocument() + }) + + it('Arguments component should be in the Document when Command is matched and argList exists', () => { + const argList = ['key', 'field'].map((field, i) =>
{field}
) + const { queryByTestId } = render( + + ) + + const cliHelperTitle = queryByTestId('cli-helper-arguments') + + expect(cliHelperTitle).toBeInTheDocument() + }) + + it('Search results should be in the Document when Command is matched', () => { + render() + const cliHelperSearchResultsTitle = screen.queryAllByTestId(/cli-helper-output-title/) + + expect(cliHelperSearchResultsTitle).toHaveLength(2) + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/CliHelper.tsx b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/CliHelper.tsx new file mode 100644 index 0000000000..fa3896c2c9 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/CliHelper.tsx @@ -0,0 +1,117 @@ +import React, { ReactElement } from 'react' +import { EuiLink, EuiText, EuiTextColor } from '@elastic/eui' +import { CommandGroup } from 'uiSrc/constants' +import { getDocUrlForCommand } from 'uiSrc/utils' + +import CliCommandInfo from '../../cli-command-info' +import CliSearchWrapper from '../../cli-search' +import CliSearchOutput from '../../cli-search-output' +import styles from './styles.module.scss' + +export interface Props { + commandLine: string; + isSearching: boolean; + searchedCommands: string[]; + argString: string; + argList: ReactElement[]; + summary: string; + group: CommandGroup | string; + complexity: string; + complexityShort: string; + since: string; +} + +const CliHelper = (props: Props) => { + const { + commandLine = '', + isSearching = false, + searchedCommands = [], + argString = '', + argList = [], + summary = '', + group = CommandGroup.Generic, + complexity = '', + complexityShort = '', + since = '', + } = props + + const readMore = (commandName = '') => { + const docUrl = getDocUrlForCommand(commandName, group) + return ( + + Read more + + ) + } + + return ( +
+
+ +
+
+ {isSearching && ( + + )} + {!isSearching && ( + <> + {commandLine && ( +
+ + {summary && ( + + {summary} + {' '} + {readMore(commandLine)} + + )} + {!!argList.length && ( +
+ + Arguments: + + {argList} +
+ )} + {since && ( +
+ + Since: + + {since} +
+ )} + {!complexityShort && complexity && ( +
+ + Complexity: + + {complexity} +
+ )} +
+ )} + {!commandLine && ( + + Enter any command in CLI or use search to see detailed information. + + )} + + )} +
+
+ ) +} + +export default CliHelper diff --git a/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/index.ts b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/index.ts new file mode 100644 index 0000000000..358da01d4f --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/index.ts @@ -0,0 +1,3 @@ +import CliHelper from './CliHelper' + +export default CliHelper diff --git a/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/styles.module.scss new file mode 100644 index 0000000000..32d370e5d5 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/styles.module.scss @@ -0,0 +1,76 @@ +@import '@elastic/eui/src/global_styling/mixins/helpers'; +@import '@elastic/eui/src/components/table/mixins'; +@import '@elastic/eui/src/global_styling/index'; + +.container { + + height: 100%; + position: relative; + + width: 360px; + min-width: 360px; + + background-color: var(--browserTableRowEven); + text-align: left; + letter-spacing: 0; + color: var(--euiTextSubduedColor) !important; + border-top: 1px solid var(--euiColorLightShade); + + z-index: 10; +} + +.searchWrapper { + padding: 10px 10px 0 10px; + background-color: var(--browserTableRowEven); +} + +.outputWrapper { + @include euiScrollBar; + display: flex; + flex: 1; + padding: 0 10px 10px 10px; + + width: 360px; + min-width: 360px; + overflow: auto; + word-break: break-word; + height: 100%; + max-height: calc(100% - 64px); +} + +.defaultScreen { + text-align: center; + display: flex; + align-items: center; + justify-content: center; + flex-grow: 1; + line-height: 21px; +} + +.summary { + font: normal normal normal 13px/18px Graphik, sans-serif !important; + padding: 10px 0 5px; +} + +.field { + padding-top: 12px; + font: normal normal normal 13px/17px Graphik, sans-serif !important; +} + +.fieldTitle { + font: normal normal 500 14px/17px Graphik, sans-serif !important; + color: var(--euiTextSubduedColorHover); + padding-bottom: 3px; +} + +.arg { + padding: 3px 10px; + margin: 0 -10px; + &:nth-child(2n) { + background-color: var(--euiColorEmptyShade); + } +} +.badge { + background-color: var(--badgeBackgroundColor) !important; + margin-left: 5px; +} diff --git a/redisinsight/ui/src/components/cli/components/cli-helper/CliHelperWrapper.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelperWrapper.spec.tsx new file mode 100644 index 0000000000..43c5b03b07 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelperWrapper.spec.tsx @@ -0,0 +1,195 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' +import { cleanup, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import { ICommands, MOCK_COMMANDS_SPEC } from 'uiSrc/constants' +import CliHelperWrapper from './CliHelperWrapper' + +const ALL_REDIS_COMMANDS: ICommands = MOCK_COMMANDS_SPEC +const redisCommandsPath = 'uiSrc/slices/app/redis-commands' +const cliHelperTestId = 'cli-helper' + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/slices/cli/cli-settings', () => ({ + ...jest.requireActual('uiSrc/slices/cli/cli-settings'), + cliSettingsSelector: jest.fn().mockReturnValue({ + matchedCommand: '', + isSearching: false, + isEnteringCommand: false, + searchedCommand: '', + searchingCommand: '', + }), +})) + +jest.mock(redisCommandsPath, () => { + const defaultState = jest.requireActual(redisCommandsPath).initialState + const { MOCK_COMMANDS_SPEC, MOCK_COMMANDS_ARRAY } = jest.requireActual('uiSrc/constants') + return { + ...jest.requireActual(redisCommandsPath), + appRedisCommandsSelector: jest.fn().mockReturnValue({ + ...defaultState, + spec: MOCK_COMMANDS_SPEC, + commandsArray: MOCK_COMMANDS_ARRAY + }), + } +}) + +interface IMockedCommands { + matchedCommand: string; + argStr?: string; + argListText?: string; + complexityShort?: string; +} + +const mockedCommands: IMockedCommands[] = [ + { + matchedCommand: 'xgroup', + argStr: + 'XGROUP [CREATE key groupname ID|$ [MKSTREAM]] [SETID key groupname ID|$] [DESTROY key groupname] [CREATECONSUMER key groupname consumername] [DELCONSUMER key groupname consumername]', + argListText: + 'Arguments:[CREATE key groupname id [MKSTREAM]]Optional[SETID key groupname id]Optional[DESTROY key groupname]Optional[CREATECONSUMER key groupname consumername]Optional[DELCONSUMER key groupname consumername]Optional', + }, + { + matchedCommand: 'hset', + argStr: 'HSET key field value [field value ...]', + argListText: 'Arguments:keyRequiredfield valueMultiple', + }, + { + matchedCommand: 'acl setuser', + argStr: 'ACL SETUSER username [rule [rule ...]]', + argListText: 'Arguments:usernameRequired[rule]Multiple', + }, + { + matchedCommand: 'bitfield', + argStr: + 'BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP|SAT|FAIL]', + argListText: + 'Arguments:keyRequired[GET type offset]Optional[SET type offset value]Optional[INCRBY type offset increment]Optional[OVERFLOW WRAP|SAT|FAIL]Optional', + }, + { + matchedCommand: 'client kill', + argStr: + 'CLIENT KILL [ip:port] [ID client-id] [TYPE normal|master|slave|pubsub] [USER username] [ADDR ip:port] [LADDR ip:port] [SKIPME yes/no]', + argListText: + 'Arguments:[ip:port]Optional[ID client-id]Optional[TYPE normal|master|slave|pubsub]Optional[USER username]Optional[ADDR ip:port]Optional[LADDR ip:port]Optional[SKIPME yes/no]Optional', + }, + { + matchedCommand: 'geoadd', + argStr: 'GEOADD key [NX|XX] [CH] longitude latitude member [longitude latitude member ...]', + argListText: + 'Arguments:keyRequired[condition]Optional[change]Optionallongitude latitude memberMultiple', + }, + { + matchedCommand: 'zadd', + argStr: 'ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]', + argListText: + 'Arguments:keyRequired[condition]Optional[comparison]Optional[change]Optional[increment]Optionalscore memberMultiple', + }, +] + +describe('CliBodyWrapper', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('Title should be rendered according mocked data', () => { + const titleArgsId = 'cli-helper-title-args' + + mockedCommands.forEach(({ matchedCommand, argStr = '' }) => { + cliSettingsSelector.mockImplementation(() => ({ + matchedCommand, + isEnteringCommand: true, + })) + + const { unmount } = render() + + expect(screen.getByTestId(cliHelperTestId)).toBeInTheDocument() + expect(screen.getByTestId(titleArgsId)).toHaveTextContent(argStr) + + unmount() + }) + }) + + it('Arguments list text should be rendered according mocked data', () => { + const argsId = 'cli-helper-arguments' + + mockedCommands.forEach(({ matchedCommand, argListText = '' }) => { + cliSettingsSelector.mockImplementation(() => ({ + matchedCommand, + isEnteringCommand: true, + })) + + const { unmount } = render() + + expect(screen.getByTestId(cliHelperTestId)).toBeInTheDocument() + expect(screen.getByTestId(argsId)).toHaveTextContent(argListText) + + unmount() + }) + }) + + it('Since should be rendered according mocked data', () => { + const sinceId = 'cli-helper-since' + + mockedCommands.forEach(({ matchedCommand = '' }) => { + const since = ALL_REDIS_COMMANDS[matchedCommand?.toUpperCase()]?.since + + cliSettingsSelector.mockImplementation(() => ({ + matchedCommand, + isEnteringCommand: true, + })) + + const { unmount } = render() + + expect(screen.getByTestId(cliHelperTestId)).toBeInTheDocument() + expect(screen.getByTestId(sinceId)).toHaveTextContent(since) + + unmount() + }) + }) + + it('Complexity should be rendered according mocked data', () => { + const complexityId = 'cli-helper-complexity' + + mockedCommands.forEach(({ matchedCommand = '' }) => { + const complexity = ALL_REDIS_COMMANDS[matchedCommand?.toUpperCase()]?.complexity + + cliSettingsSelector.mockImplementation(() => ({ + matchedCommand, + isEnteringCommand: true, + })) + + const { unmount } = render() + + expect(screen.getByTestId(cliHelperTestId)).toBeInTheDocument() + + if (complexity) { + expect(screen.getByTestId(complexityId)).toBeInTheDocument() + expect(screen.getByTestId(complexityId)).toHaveTextContent(complexity) + } + + unmount() + }) + }) + + it('should render search results', () => { + mockedCommands.forEach(({ matchedCommand }) => { + cliSettingsSelector.mockImplementation(() => ({ + searchingCommand: matchedCommand, + searchedCommand: '', + isSearching: true, + })) + const { unmount } = render() + expect( + screen.getByTestId(`cli-helper-output-title-${matchedCommand.toUpperCase()}`) + ).toBeInTheDocument() + unmount() + }) + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-helper/CliHelperWrapper.tsx b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelperWrapper.tsx new file mode 100644 index 0000000000..43cccbbfe1 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-helper/CliHelperWrapper.tsx @@ -0,0 +1,107 @@ +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui' +import React, { ReactElement, useEffect } from 'react' +import { useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import { + CommandGroup, + ICommand, + ICommandArgGenerated, +} from 'uiSrc/constants' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' +import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' +import { generateArgs, generateArgsNames, getComplexityShortNotation } from 'uiSrc/utils' +import CliHelper from './CliHelper' + +import styles from './CliHelper/styles.module.scss' + +const CliHelperWrapper = () => { + const { + matchedCommand, + searchedCommand, + isSearching, + isEnteringCommand, + searchingCommand, + searchingCommandFilter + } = useSelector(cliSettingsSelector) + const { spec: ALL_REDIS_COMMANDS, commandsArray: KEYS_OF_COMMANDS } = useSelector(appRedisCommandsSelector) + const { instanceId = '' } = useParams<{ instanceId: string }>() + const lastMatchedCommand = (isEnteringCommand && matchedCommand) ? matchedCommand : searchedCommand + let searchedCommands: string[] = [] + + useEffect(() => { + if (!isSearching && isEnteringCommand && matchedCommand) { + sendEventTelemetry({ + event: TelemetryEvent.COMMAND_HELPER_INFO_DISPLAYED_FOR_CLI_INPUT, + eventData: { + databaseId: instanceId, + command: matchedCommand + } + }) + } + }, [isSearching, isEnteringCommand, matchedCommand]) + + const { + arguments: args = [], + summary = '', + group = CommandGroup.Generic, + complexity = '', + since = '', + }: ICommand = ALL_REDIS_COMMANDS[lastMatchedCommand.toUpperCase()] ?? {} + + if (isSearching) { + searchedCommands = KEYS_OF_COMMANDS + .filter((command) => { + const isSuitableForFilter = searchingCommandFilter + ? ALL_REDIS_COMMANDS[command].group === searchingCommandFilter + : true + return isSuitableForFilter && command.toLowerCase().indexOf(searchingCommand.toLowerCase()) > -1 + }) + } + + const generatedArgs = generateArgs(args) + const complexityShort = getComplexityShortNotation(complexity) + const argString = [lastMatchedCommand.toUpperCase(), ...generateArgsNames(args)].join(' ') + + const generateArgData = (arg: ICommandArgGenerated, i: number): ReactElement => { + const type = arg.multiple ? 'Multiple' : arg.optional ? 'Optional' : 'Required' + return ( + + {arg.generatedName} + + + + {type} + + + + + ) + } + + return ( + generateArgData(obj, i))} + /> + ) +} + +export default React.memo(CliHelperWrapper) diff --git a/redisinsight/ui/src/components/cli/components/cli-helper/index.ts b/redisinsight/ui/src/components/cli/components/cli-helper/index.ts new file mode 100644 index 0000000000..0808ce3c8f --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-helper/index.ts @@ -0,0 +1,3 @@ +import CliHelperWrapper from './CliHelperWrapper' + +export default CliHelperWrapper diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/CliAutocomplete.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/CliAutocomplete.spec.tsx new file mode 100644 index 0000000000..7937901f18 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/CliAutocomplete.spec.tsx @@ -0,0 +1,113 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { setMatchedCommand, clearSearchingCommand } from 'uiSrc/slices/cli/cli-settings' +import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' +import CliAutocomplete, { Props } from './CliAutocomplete' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +const CliAutocompleteTestId = 'cli-command-autocomplete' +const scanCommand = 'scan' +const scanArgs = [ + { + name: 'cursor', + type: 'integer', + }, + { + command: 'MATCH', + name: 'pattern', + type: 'pattern', + optional: true, + }, + { + command: 'COUNT', + name: 'count', + type: 'integer', + optional: true, + }, + { + command: 'TYPE', + name: 'type', + type: 'string', + optional: true, + }, +] + +describe('CliAutocomplete', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('Autocomplete should not be in the Document with empty array of arguments prop ', () => { + const command = 'clear' + + const { queryByTestId } = render( + + ) + + const autocompleteComponent = queryByTestId(CliAutocompleteTestId) + + expect(autocompleteComponent).not.toBeInTheDocument() + }) + + it('Autocomplete should be in Document with "scan" command ', () => { + const { queryByTestId } = render( + + ) + + const autocompleteComponent = queryByTestId(CliAutocompleteTestId) + + expect(autocompleteComponent).toBeInTheDocument() + }) + + it('should "setMatchedCommand" & "clearSearchingCommand" action be called after unmount with empty string', () => { + const { unmount } = render( + + ) + + unmount() + + const expectedActions = [setMatchedCommand(''), clearSearchingCommand()] + expect(store.getActions().slice(-2)).toEqual(expectedActions) + }) + + it('Autocomplete should be only with optional args for "scan" command with filled in required args ', () => { + const autocompleteOptionalText = '[MATCH pattern] [COUNT count] [TYPE type]' + const { queryByTestId } = render( + + ) + + const autocompleteComponent = queryByTestId(CliAutocompleteTestId) + + expect(autocompleteOptionalText).toEqual(autocompleteComponent?.textContent) + }) + + it('Autocomplete should be only with optional args for "scan" command with filled in required args and several optional args', () => { + const autocompleteOptionalText = '[MATCH pattern] [COUNT count] [TYPE type]' + const { queryByTestId } = render( + + ) + + const autocompleteComponent = queryByTestId(CliAutocompleteTestId) + + expect(autocompleteOptionalText).toEqual(autocompleteComponent?.textContent) + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/CliAutocomplete.tsx b/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/CliAutocomplete.tsx new file mode 100644 index 0000000000..51097a0e39 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/CliAutocomplete.tsx @@ -0,0 +1,67 @@ +import React, { useEffect } from 'react' +import { findIndex } from 'lodash' +import { useDispatch } from 'react-redux' + +import { ICommandArg } from 'uiSrc/constants' +import { generateArgsNames } from 'uiSrc/utils' +import { setMatchedCommand, clearSearchingCommand } from 'uiSrc/slices/cli/cli-settings' + +import styles from './styles.module.scss' + +export interface Props { + commandName: string; + wordsTyped: number; + arguments?: ICommandArg[]; +} + +const CliAutocomplete = (props: Props) => { + const { commandName = '', arguments: args = [], wordsTyped } = props + + const dispatch = useDispatch() + + useEffect(() => { + dispatch(setMatchedCommand(commandName)) + dispatch(clearSearchingCommand()) + }, [commandName]) + + useEffect(() => () => { + dispatch(setMatchedCommand('')) + dispatch(clearSearchingCommand()) + }, []) + + let argsList: any[] | string = [] + let untypedArgs: any[] | string = [] + + const getUntypedArgs = () => { + const firstOptionalArgIndex = findIndex(argsList, (arg: string = '') => + arg.toString().includes('[')) + + const isOnlyOptionalLeft = wordsTyped - commandName.split(' ').length >= firstOptionalArgIndex + && firstOptionalArgIndex > -1 + + if (isOnlyOptionalLeft) { + return firstOptionalArgIndex + } + + return wordsTyped - commandName.split(' ').length + } + + if (args.length) { + argsList = generateArgsNames(args) + + untypedArgs = argsList.slice(getUntypedArgs()).join(' ') + argsList = argsList.join(' ') + } + + return ( + <> + {!!args.length && argsList && untypedArgs && ( + + {untypedArgs} + + )} + + ) +} + +export default CliAutocomplete diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/index.ts b/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/index.ts new file mode 100644 index 0000000000..72fbfbcc17 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/index.ts @@ -0,0 +1,3 @@ +import CliAutocomplete from './CliAutocomplete' + +export default CliAutocomplete diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/styles.module.scss new file mode 100644 index 0000000000..49c0e18245 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/styles.module.scss @@ -0,0 +1,10 @@ +.container { + font: normal normal normal 13px/15px Inconsolata !important; + background-color: var(--tableDarkestBorderColor); + opacity: 0.8; + margin-left: 1px; +} + +.params { + padding: 0 5px; +} diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliInput/CliInput.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-input/CliInput/CliInput.spec.tsx new file mode 100644 index 0000000000..ac55b26727 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliInput/CliInput.spec.tsx @@ -0,0 +1,48 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { cleanup, mockedStore, render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import CliInput, { Props } from './CliInput' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('CliInput', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + // It's not possible to simulate events on contenteditable with testing-react-library, + // or any testing library that uses js - dom, because of a limitation on js - dom itself. + // https://github.com/testing-library/dom-testing-library/pull/235 + it.skip('"onChange" should be called', async () => { + const command = 'keys *' + const setCommandMock = jest.fn() + + render() + + const cliInput = screen.getByTestId('cli-command') + + fireEvent.blur(cliInput, { target: { innerHTML: command } }) + + expect(setCommandMock).toBeCalledTimes(command.length) + }) + + it('onMouseUp should be called', async () => { + const setCommandMock = jest.fn() + + render() + + const cliInput = screen.getByTestId('cli-command') + + fireEvent.mouseUp(cliInput) + + expect(setCommandMock).not.toBeCalled() + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliInput/CliInput.tsx b/redisinsight/ui/src/components/cli/components/cli-input/CliInput/CliInput.tsx new file mode 100644 index 0000000000..0bba04edb6 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliInput/CliInput.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { ContentEditableEvent } from 'react-contenteditable' + +import { ContentEditable } from 'uiSrc/components' +import { parseContentEditableChangeHtml } from 'uiSrc/components/ContentEditable' + +import styles from './styles.module.scss' + +export interface Props { + command: string; + setInputEl: Function; + setCommand: (command: string) => void; + onKeyDown: (event: React.KeyboardEvent) => void; +} + +const CliInput = (props: Props) => { + const { command = '', setInputEl, setCommand, onKeyDown } = props + + const onMouseUp = (event: React.MouseEvent) => { + event.stopPropagation() + } + + const onChange = (e: ContentEditableEvent) => { + setCommand(parseContentEditableChangeHtml(e.target.value ?? '')) + } + + return ( + <> + >  + + + ) +} + +export default CliInput diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliInput/index.ts b/redisinsight/ui/src/components/cli/components/cli-input/CliInput/index.ts new file mode 100644 index 0000000000..a3c1a696d8 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliInput/index.ts @@ -0,0 +1,3 @@ +import CliInput from './CliInput' + +export default CliInput diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliInput/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-input/CliInput/styles.module.scss new file mode 100644 index 0000000000..ff77f5d285 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliInput/styles.module.scss @@ -0,0 +1,7 @@ +#command { + font: normal normal bold 14px/15px Inconsolata !important; + color: var(--textColorShade); + caret-color: var(--euiColorFullShade); + min-width: 5px; + display: inline; +} diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliInputWrapper.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-input/CliInputWrapper.spec.tsx new file mode 100644 index 0000000000..5b6679ca4b --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliInputWrapper.spec.tsx @@ -0,0 +1,60 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { cleanup, mockedStore, render, screen } from 'uiSrc/utils/test-utils' +import CliInputWrapper, { Props } from './CliInputWrapper' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +const autocompleteTestId = 'cli-command-autocomplete' +const redisCommandsPath = 'uiSrc/slices/app/redis-commands' + +jest.mock(redisCommandsPath, () => { + const defaultState = jest.requireActual(redisCommandsPath).initialState + const { MOCK_COMMANDS_SPEC, MOCK_COMMANDS_ARRAY } = jest.requireActual('uiSrc/constants') + return { + ...jest.requireActual(redisCommandsPath), + appRedisCommandsSelector: jest.fn().mockReturnValue({ + ...defaultState, + spec: MOCK_COMMANDS_SPEC, + commandsArray: Object.keys(MOCK_COMMANDS_ARRAY).sort() + }), + } +}) + +describe('CliInputWrapper', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('"get" command (with args) should render CliAutocomplete', () => { + const setCommandMock = jest.fn() + + const command = 'get' + + render( + + ) + + expect(screen.getByTestId(autocompleteTestId)).toBeInTheDocument() + }) + + it('"client info" command (without args) should not render CliAutocomplete', () => { + const setCommandMock = jest.fn() + + const command = 'client info' + + const { queryByTestId } = render( + + ) + + expect(queryByTestId(autocompleteTestId)).not.toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-input/CliInputWrapper.tsx b/redisinsight/ui/src/components/cli/components/cli-input/CliInputWrapper.tsx new file mode 100644 index 0000000000..fba2233f58 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/CliInputWrapper.tsx @@ -0,0 +1,44 @@ +import { isUndefined } from 'lodash' +import React from 'react' +import { useSelector } from 'react-redux' +import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' +import CliAutocomplete from './CliAutocomplete' + +import CliInput from './CliInput' + +export interface Props { + command: string; + wordsTyped: number; + setInputEl: Function; + setCommand: (command: string) => void; + onKeyDown: (event: React.KeyboardEvent) => void; +} + +const CliInputWrapper = (props: Props) => { + const { command = '', wordsTyped, setInputEl, setCommand, onKeyDown } = props + const { spec: ALL_REDIS_COMMANDS } = useSelector(appRedisCommandsSelector) + const [firstCommand, secondCommand] = command.split(' ') + const firstCommandMatch = firstCommand.toUpperCase() + const secondCommandMatch = `${firstCommandMatch} ${secondCommand ? secondCommand.toUpperCase() : null}` + + const matchedCmd = ALL_REDIS_COMMANDS[firstCommandMatch] || ALL_REDIS_COMMANDS[secondCommandMatch] + const commandName = !isUndefined(ALL_REDIS_COMMANDS[secondCommandMatch]) + ? `${firstCommand} ${secondCommand}` + : firstCommand + + return ( + <> + + {matchedCmd && ( + + )} + + ) +} + +export default CliInputWrapper diff --git a/redisinsight/ui/src/components/cli/components/cli-input/index.ts b/redisinsight/ui/src/components/cli/components/cli-input/index.ts new file mode 100644 index 0000000000..db293fe916 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-input/index.ts @@ -0,0 +1,3 @@ +import CliInputWrapper from './CliInputWrapper' + +export default CliInputWrapper diff --git a/redisinsight/ui/src/components/cli/components/cli-search-output/CliSearchOutput.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-search-output/CliSearchOutput.spec.tsx new file mode 100644 index 0000000000..f42df356b0 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search-output/CliSearchOutput.spec.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { cleanup, mockedStore, render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import { setSearchedCommand } from 'uiSrc/slices/cli/cli-settings' + +import CliSearchOutput from './CliSearchOutput' + +const redisCommandsPath = 'uiSrc/slices/app/redis-commands' +let store: typeof mockedStore + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +interface IMockedCommands { + matchedCommand: string; + argStr?: string; + summary?: string; +} + +const mockedCommands: IMockedCommands[] = [ + { + matchedCommand: 'HSET', + argStr: 'key field value [field value ...]', + }, + { + matchedCommand: 'GEOADD', + argStr: 'key [NX|XX] [CH] longitude latitude member [longitude latitude member ...]', + }, + { + matchedCommand: 'ZADD', + argStr: 'key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]', + }, + { + matchedCommand: 'RESET', + summary: 'Reset the connection', + }, +] + +jest.mock(redisCommandsPath, () => { + const defaultState = jest.requireActual(redisCommandsPath).initialState + const { MOCK_COMMANDS_SPEC, MOCK_COMMANDS_ARRAY } = jest.requireActual('uiSrc/constants') + return { + ...jest.requireActual(redisCommandsPath), + appRedisCommandsSelector: jest.fn().mockReturnValue({ + ...defaultState, + spec: MOCK_COMMANDS_SPEC, + commandsArray: MOCK_COMMANDS_ARRAY + }), + } +}) + +describe('CliSearchOutput', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render no results', () => { + render() + expect(screen.getByTestId('search-cmds-no-results')).toBeInTheDocument() + }) + + it('should render searched commands results', () => { + const searchedCommands = mockedCommands.map((command) => command.matchedCommand) + render() + searchedCommands.forEach((command) => { + expect(screen.getByTestId(`cli-helper-output-title-${command}`)).toBeInTheDocument() + }) + }) + + it('should render searched commands results with proper args or summary', () => { + const searchedCommands = mockedCommands.map((command) => command.matchedCommand) + render() + mockedCommands.forEach((command) => { + if (command.argStr) { + expect( + screen.getByTestId(`cli-helper-output-args-${command.matchedCommand}`) + ).toHaveTextContent(command.argStr || '') + } else { + expect( + screen.getByTestId(`cli-helper-output-summary-${command.matchedCommand}`) + ).toHaveTextContent(command.summary || '') + } + }) + }) + + it('should call setSearchedCommand after click any command', () => { + const searchedCommands = mockedCommands.map((command) => command.matchedCommand) + const anySearchCommand = searchedCommands[0] + render() + fireEvent.click(screen.getByTestId(`cli-helper-output-title-${anySearchCommand}`)) + expect(store.getActions()).toEqual([setSearchedCommand(anySearchCommand)]) + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-search-output/CliSearchOutput.tsx b/redisinsight/ui/src/components/cli/components/cli-search-output/CliSearchOutput.tsx new file mode 100644 index 0000000000..3cc88d3b8a --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search-output/CliSearchOutput.tsx @@ -0,0 +1,103 @@ +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' +import { EuiFlexItem, EuiLink, EuiText, EuiFlexGroup, EuiTextColor } from '@elastic/eui' +import { useParams } from 'react-router-dom' + +import { generateArgsNames } from 'uiSrc/utils' +import { setSearchedCommand } from 'uiSrc/slices/cli/cli-settings' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' + +import styles from './styles.module.scss' + +export interface Props { + searchedCommands: string[]; +} + +const CliSearchOutput = ({ searchedCommands }: Props) => { + const { instanceId = '' } = useParams<{ instanceId: string }>() + const dispatch = useDispatch() + const { spec: ALL_REDIS_COMMANDS } = useSelector(appRedisCommandsSelector) + + const handleClickCommand = (e: React.MouseEvent, command: string) => { + e.preventDefault() + sendEventTelemetry({ + event: TelemetryEvent.COMMAND_HELPER_COMMAND_OPENED, + eventData: { + databaseId: instanceId, + command + } + }) + dispatch(setSearchedCommand(command)) + } + + const renderDescription = (command: string) => { + const args = ALL_REDIS_COMMANDS[command].arguments || [] + if (args.length) { + const argString = generateArgsNames(args).join(' ') + return ( + + {argString} + + ) + } + return ( + + {ALL_REDIS_COMMANDS[command].summary} + + ) + } + + return ( + <> + {searchedCommands.length > 0 && ( +
+ {searchedCommands.map((command: string) => ( + + + + ) => { + handleClickCommand(e, command) + }} + className={styles.title} + data-testid={`cli-helper-output-title-${command}`} + > + {command} + + + + + {renderDescription(command)} + + + ))} +
+ )} + {searchedCommands.length === 0 && ( +
+ + No results found. + +
+ )} + + ) +} + +export default CliSearchOutput diff --git a/redisinsight/ui/src/components/cli/components/cli-search-output/index.ts b/redisinsight/ui/src/components/cli/components/cli-search-output/index.ts new file mode 100644 index 0000000000..12ac491dd0 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search-output/index.ts @@ -0,0 +1,3 @@ +import CliSearchOutput from './CliSearchOutput' + +export default CliSearchOutput diff --git a/redisinsight/ui/src/components/cli/components/cli-search-output/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-search-output/styles.module.scss new file mode 100644 index 0000000000..624140096b --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search-output/styles.module.scss @@ -0,0 +1,24 @@ +.defaultScreen { + text-align: center; + display: flex; + align-items: center; + justify-content: center; + flex-grow: 1; + line-height: 21px; +} + +.description, .description div { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.title { + &:global(.euiLink) { + color: var(--euiTextSubduedColorHover) !important; + } +} + +.summary, .summary div { + color: var(--inputPlaceHolderColor) !important; +} diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/CliSearchFilter.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/CliSearchFilter.spec.tsx new file mode 100644 index 0000000000..1c90df946c --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/CliSearchFilter.spec.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import { FILTER_GROUP_TYPE_OPTIONS } from './constants' +import CliSearchFilter from './CliSearchFilter' + +describe('CliSearchFilter', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call submitFilter after choose options', () => { + const submitFilter = jest.fn() + const { queryByText } = render() + fireEvent.click(screen.getByTestId('select-filter-group-type')) + fireEvent.click(queryByText(FILTER_GROUP_TYPE_OPTIONS[0].text) || document) + + expect(submitFilter).toBeCalledWith(FILTER_GROUP_TYPE_OPTIONS[0].value) + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/CliSearchFilter.tsx b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/CliSearchFilter.tsx new file mode 100644 index 0000000000..06e07768e6 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/CliSearchFilter.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react' +import cx from 'classnames' +import { + EuiIcon, + EuiOutsideClickDetector, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText +} from '@elastic/eui' +import { useSelector } from 'react-redux' + +import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' +import { FILTER_GROUP_TYPE_OPTIONS } from 'uiSrc/components/cli/components/cli-search/CliSearchFilter/constants' + +import styles from './styles.module.scss' + +export interface Props { + submitFilter: (type: string) => void; + isLoading?: boolean; +} + +const CliSearchFilter = ({ submitFilter, isLoading }: Props) => { + const [isSelectOpen, setIsSelectOpen] = useState(false) + const [typeSelected, setTypeSelected] = useState('') + + const { isEnteringCommand, matchedCommand } = useSelector(cliSettingsSelector) + + useEffect(() => { + if (isEnteringCommand && matchedCommand) { + setTypeSelected('') + } + }, [isEnteringCommand]) + + useEffect(() => { + setTypeSelected('') + }, [matchedCommand]) + + const options: EuiSuperSelectOption[] = FILTER_GROUP_TYPE_OPTIONS.map( + (item) => { + const { value, text } = item + return { + value, + inputDisplay: ( + + {text} + + ), + dropdownDisplay: {text}, + 'data-test-subj': `filter-option-group-type-${value}`, + } + } + ) + + const onChangeType = (initValue: string) => { + const value = typeSelected === initValue ? '' : initValue + setTypeSelected(value) + setIsSelectOpen(false) + submitFilter(value) + } + + return ( + setIsSelectOpen(false)} + > +
+ {!typeSelected && ( +
!isLoading && setIsSelectOpen(!isSelectOpen)} + > + +
+ )} + onChangeType(value)} + data-testid="select-filter-group-type" + /> +
+
+ ) +} + +export default CliSearchFilter diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/constants.ts b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/constants.ts new file mode 100644 index 0000000000..46e177974b --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/constants.ts @@ -0,0 +1,72 @@ +import { CommandGroup } from 'uiSrc/constants' + +export const FILTER_GROUP_TYPE_OPTIONS = [ + { + text: 'Server', + value: CommandGroup.Server, + }, + { + text: 'String', + value: CommandGroup.String, + }, + { + text: 'Connection', + value: CommandGroup.Connection, + }, + { + text: 'List', + value: CommandGroup.List, + }, + { + text: 'Zset', + value: CommandGroup.SortedSet, + }, + { + text: 'Cluster', + value: CommandGroup.Cluster, + }, + { + text: 'Generic', + value: CommandGroup.Generic, + }, + { + text: 'Transactions', + value: CommandGroup.Transactions, + }, + { + text: 'Scripting', + value: CommandGroup.Scripting, + }, + { + text: 'Geo', + value: CommandGroup.Geo, + }, + { + text: 'Hash', + value: CommandGroup.Hash, + }, + { + text: 'HyperLogLog', + value: CommandGroup.HyperLogLog, + }, + { + text: 'Pub/Sub', + value: CommandGroup.PubSub, + }, + { + text: 'Set', + value: CommandGroup.Set, + }, + { + text: 'Stream', + value: CommandGroup.Stream, + }, + { + text: 'Search', + value: CommandGroup.Search, + }, + { + text: 'JSON', + value: CommandGroup.JSON, + }, +] diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/index.ts b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/index.ts new file mode 100644 index 0000000000..adc57ae766 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/index.ts @@ -0,0 +1,3 @@ +import CliSearchFilter from './CliSearchFilter' + +export default CliSearchFilter diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/styles.module.scss new file mode 100644 index 0000000000..86c23fa16a --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/styles.module.scss @@ -0,0 +1,83 @@ +.container { + position: absolute; + height: 38px; + width: 180px; + + :global { + .euiFormControlLayout { + .euiSuperSelectControl { + height: 38px !important; + padding: 0 8px !important; + background-color: var(--euiColorLightShade) !important; + border-color: var(--euiColorLightShade) !important; + box-shadow: none !important; + + .euiHealth { + margin-top: 10px; + margin-left: -5px; + } + + &.euiSuperSelect--isOpen__button { + background-color: var(--euiColorLightShade) !important; + } + &:focus { + background-color: var(--euiColorLightShade) !important; + } + } + } + + .euiPopover:not(.euiSuperSelect) { + position: absolute; + z-index: 10; + top: 7px; + right: 88px; + + svg { + width: 24px !important; + height: 24px !important; + } + } + .euiFormControlLayoutIcons { + right: 80px; + } + } +} + +.filterKeyType { + height: 32px; + line-height: 16px !important; + padding: 4px !important; +} + +.controlsIcon { + cursor: pointer; + margin-left: 3px; + height: 20px !important; + width: 20px !important; + &:global(.euiIcon) { + color: var(--inputTextColor) !important; + } +} + +.selectedType { + max-width: 74px; + overflow: hidden; + text-overflow: ellipsis; + height: 36px; + font-weight: 500 !important; + line-height: 36px !important; +} + +.allTypes { + position: absolute; + top: 0; + display: flex; + align-items: center; + + width: 106px; + height: 38px; + padding-left: 12px; + + cursor: pointer; + z-index: 5; +} diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/CliSearchInput.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/CliSearchInput.spec.tsx new file mode 100644 index 0000000000..78e63dee6e --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/CliSearchInput.spec.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import CliSearchInput from './CliSearchInput' + +describe('CliSearchInput', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call submitSearch with after typing', () => { + const submitSearch = jest.fn() + render() + fireEvent.change( + screen.getByTestId('cli-helper-search'), + { target: { value: 'set' } } + ) + expect(submitSearch).toBeCalledWith('set') + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/CliSearchInput.tsx b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/CliSearchInput.tsx new file mode 100644 index 0000000000..c78b380914 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/CliSearchInput.tsx @@ -0,0 +1,51 @@ +import React, { ChangeEvent, useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import { EuiFieldSearch } from '@elastic/eui' + +import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' + +import styles from './styles.module.scss' + +export interface Props { + submitSearch: (searchValue: string) => void + isLoading?: boolean; +} + +const CliSearchInput = ({ submitSearch, isLoading = false }: Props) => { + const [searchValue, setSearchValue] = useState('') + const { isEnteringCommand, matchedCommand } = useSelector(cliSettingsSelector) + + useEffect(() => { + if (isEnteringCommand && matchedCommand) { + setSearchValue('') + } + }, [isEnteringCommand]) + + useEffect(() => { + setSearchValue('') + }, [matchedCommand]) + + const onChangeSearch = (value: string) => { + setSearchValue(value) + submitSearch(value) + } + + return ( +
+ ) => onChangeSearch(e.target.value)} + className={styles.searchInput} + data-testid="cli-helper-search" + /> +
+ ) +} + +export default CliSearchInput diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/index.ts b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/index.ts new file mode 100644 index 0000000000..e624864d52 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/index.ts @@ -0,0 +1,3 @@ +import CliSearchInput from './CliSearchInput' + +export default CliSearchInput diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/styles.module.scss new file mode 100644 index 0000000000..3729a8e34f --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/styles.module.scss @@ -0,0 +1,17 @@ +.container { + max-width: 100%; + height: 38px; + margin-left: 106px; + + :global(.euiFormControlLayout) { + max-width: calc(100%) !important; + height: 38px !important; + } +} + +.searchInput { + &:global(.euiFieldSearch) { + border: 1px solid var(--euiColorLightShade) !important; + height: 38px !important; + } +} diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchWrapper.spec.tsx b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchWrapper.spec.tsx new file mode 100644 index 0000000000..d8a250f77c --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchWrapper.spec.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/test-utils' +import { clearSearchingCommand, setSearchingCommand, setCliEnteringCommand } from 'uiSrc/slices/cli/cli-settings' +import CliSearchWrapper from './CliSearchWrapper' + +let store: typeof mockedStore +const redisCommandsPath = 'uiSrc/slices/app/redis-commands' + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock(redisCommandsPath, () => { + const defaultState = jest.requireActual(redisCommandsPath).initialState + return { + ...jest.requireActual(redisCommandsPath), + appRedisCommandsSelector: jest.fn().mockReturnValue({ + ...defaultState, + }), + } +}) + +describe('CliSearchInput', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call search action after typing', () => { + render() + fireEvent.change( + screen.getByTestId('cli-helper-search'), + { target: { value: 'set' } } + ) + const expectedActions = [setSearchingCommand('set')] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call clear search action after clear input', () => { + render() + const searchInput = screen.getByTestId('cli-helper-search') + fireEvent.change( + searchInput, + { target: { value: 'set' } } + ) + fireEvent.change( + searchInput, + { target: { value: '' } } + ) + const expectedActions = [setSearchingCommand('set'), clearSearchingCommand(), setCliEnteringCommand()] + expect(store.getActions()).toEqual(expectedActions) + }) +}) diff --git a/redisinsight/ui/src/components/cli/components/cli-search/CliSearchWrapper.tsx b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchWrapper.tsx new file mode 100644 index 0000000000..237cde17bd --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/CliSearchWrapper.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { + clearSearchingCommand, + setSearchingCommand, + setSearchingCommandFilter, + setCliEnteringCommand +} from 'uiSrc/slices/cli/cli-settings' +import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' + +import CliSearchInput from './CliSearchInput' +import CliSearchFilter from './CliSearchFilter' + +import styles from './styles.module.scss' + +const CliSearchWrapper = () => { + const { instanceId = '' } = useParams<{ instanceId: string }>() + const [filterType, setFilterType] = useState('') + const [searchValue, setSearchValue] = useState('') + const { loading } = useSelector(appRedisCommandsSelector) + const dispatch = useDispatch() + + useEffect(() => () => { + dispatch(clearSearchingCommand()) + dispatch(setCliEnteringCommand()) + }, []) + + const onChangeSearch = (value: string) => { + setSearchValue(value) + + if (value === '' && !filterType) { + dispatch(clearSearchingCommand()) + dispatch(setCliEnteringCommand()) + return + } + dispatch(setSearchingCommand(value)) + } + + const onChangeFilter = (type: string) => { + setFilterType(type) + + if (type) { + sendEventTelemetry({ + event: TelemetryEvent.COMMAND_HELPER_COMMAND_FILTERED, + eventData: { + databaseId: instanceId, + group: type + } + }) + } + + if (searchValue === '' && !type) { + dispatch(clearSearchingCommand()) + dispatch(setCliEnteringCommand()) + return + } + dispatch(setSearchingCommandFilter(type)) + } + + return ( +
+ + +
+ ) +} + +export default CliSearchWrapper diff --git a/redisinsight/ui/src/components/cli/components/cli-search/index.ts b/redisinsight/ui/src/components/cli/components/cli-search/index.ts new file mode 100644 index 0000000000..cfc80c5c7a --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/index.ts @@ -0,0 +1,3 @@ +import CliSearchWrapper from './CliSearchWrapper' + +export default CliSearchWrapper diff --git a/redisinsight/ui/src/components/cli/components/cli-search/styles.module.scss b/redisinsight/ui/src/components/cli/components/cli-search/styles.module.scss new file mode 100644 index 0000000000..42e8b31e00 --- /dev/null +++ b/redisinsight/ui/src/components/cli/components/cli-search/styles.module.scss @@ -0,0 +1,4 @@ +.searchWrapper { + margin-bottom: 16px; + position: relative; +} diff --git a/redisinsight/ui/src/components/config/Config.spec.tsx b/redisinsight/ui/src/components/config/Config.spec.tsx new file mode 100644 index 0000000000..792b5c6ea4 --- /dev/null +++ b/redisinsight/ui/src/components/config/Config.spec.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { render, mockedStore, cleanup } from 'uiSrc/utils/test-utils' + +import { + getUserConfigSettings, + setSettingsPopupState, + userSettingsSelector, +} from 'uiSrc/slices/user/user-settings' +import { getServerInfo } from 'uiSrc/slices/app/info' +import { processCliClient } from 'uiSrc/slices/cli/cli-settings' +import { getRedisCommands } from 'uiSrc/slices/app/redis-commands' +import Config from './Config' + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/slices/user/user-settings', () => ({ + ...jest.requireActual('uiSrc/slices/user/user-settings'), + userSettingsSelector: jest.fn().mockReturnValue({ + config: { + agreements: {}, + }, + spec: { + agreements: {}, + }, + }), +})) + +describe('Config', () => { + it('should render', () => { + render() + const afterRenderActions = [ + getServerInfo(), + processCliClient(), + getRedisCommands(), + getUserConfigSettings() + ] + expect(store.getActions()).toEqual([...afterRenderActions]) + }) + + it('should call setSettingsPopupState with difference of agreements', () => { + const userSettingsSelectorMock = jest.fn().mockReturnValue({ + config: { + agreements: {}, + }, + spec: { + agreements: { + eula: { + defaultValue: false, + required: true, + editable: false, + since: '1.0.0', + title: 'EULA: RedisInsight License Terms', + label: 'Label', + }, + }, + }, + }) + userSettingsSelector.mockImplementation(userSettingsSelectorMock) + render() + const afterRenderActions = [ + getServerInfo(), + processCliClient(), + getRedisCommands(), + getUserConfigSettings(), + setSettingsPopupState(true), + ] + expect(store.getActions()).toEqual([...afterRenderActions]) + }) +}) diff --git a/redisinsight/ui/src/components/config/Config.tsx b/redisinsight/ui/src/components/config/Config.tsx new file mode 100644 index 0000000000..4f620fefab --- /dev/null +++ b/redisinsight/ui/src/components/config/Config.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useLocation } from 'react-router-dom' + +import { + fetchUserConfigSettings, + fetchUserSettingsSpec, + userSettingsSelector, + setSettingsPopupState, +} from 'uiSrc/slices/user/user-settings' +import { + fetchServerInfo, + appAnalyticsInfoSelector, + appServerInfoSelector, + setAnalyticsIdentified, +} from 'uiSrc/slices/app/info' + +import { checkIsAnalyticsGranted, getTelemetryService } from 'uiSrc/telemetry' +import { setFavicon, isDifferentConsentsExists } from 'uiSrc/utils' +import { fetchUnsupportedCliCommandsAction } from 'uiSrc/slices/cli/cli-settings' +import { fetchRedisCommandsInfo } from 'uiSrc/slices/app/redis-commands' +import favicon from 'uiSrc/assets/favicon.ico' + +const SETTINGS_PAGE_PATH = '/settings' +const Config = () => { + const serverInfo = useSelector(appServerInfoSelector) + const { config, spec } = useSelector(userSettingsSelector) + const { segmentWriteKey } = useSelector(appAnalyticsInfoSelector) + + const { pathname } = useLocation() + + const dispatch = useDispatch() + useEffect(() => { + setFavicon(favicon) + + dispatch(fetchServerInfo()) + dispatch(fetchUnsupportedCliCommandsAction()) + dispatch(fetchRedisCommandsInfo()) + + // fetch config settings, after that take spec + if (pathname !== SETTINGS_PAGE_PATH) { + dispatch(fetchUserConfigSettings(() => dispatch(fetchUserSettingsSpec()))) + } + }, []) + + useEffect(() => { + if (config && spec) { + checkSettingsToShowPopup() + } + }, [spec]) + + useEffect(() => { + if (serverInfo && checkIsAnalyticsGranted()) { + (async () => { + const telemetryService = getTelemetryService(segmentWriteKey) + await telemetryService.identify({ installationId: serverInfo.id }) + + dispatch(setAnalyticsIdentified(true)) + })() + } + }, [serverInfo, config]) + + const checkSettingsToShowPopup = () => { + const specConsents = spec?.agreements + const appliedConsents = config?.agreements + + if (isDifferentConsentsExists(specConsents, appliedConsents)) { + dispatch(setSettingsPopupState(true)) + } + } + + return null +} + +export default Config diff --git a/redisinsight/ui/src/components/config/index.ts b/redisinsight/ui/src/components/config/index.ts new file mode 100644 index 0000000000..1fca4da459 --- /dev/null +++ b/redisinsight/ui/src/components/config/index.ts @@ -0,0 +1,3 @@ +import Config from './Config' + +export default Config diff --git a/redisinsight/ui/src/components/consents-settings/ConsentsSettings.spec.tsx b/redisinsight/ui/src/components/consents-settings/ConsentsSettings.spec.tsx new file mode 100644 index 0000000000..27040ab38a --- /dev/null +++ b/redisinsight/ui/src/components/consents-settings/ConsentsSettings.spec.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { + render, + screen, + fireEvent, + mockedStore, + cleanup, + clearStoreActions, + waitFor, +} from 'uiSrc/utils/test-utils' +import { updateUserConfigSettings } from 'uiSrc/slices/user/user-settings' +import ConsentsSettings from './ConsentsSettings' + +const BTN_SUBMIT = 'btn-submit' + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) +const COMMON_CONSENT_CONTENT = { + defaultValue: false, + required: false, + editable: true, + disabled: false, + displayInSetting: true, + since: '1.0.0', + title: 'Title', + label: 'Text', +} + +jest.mock('uiSrc/slices/user/user-settings', () => ({ + ...jest.requireActual('uiSrc/slices/user/user-settings'), + userSettingsSelector: jest.fn().mockReturnValue({ + isShowConceptsPopup: true, + config: { + agreements: { + eula: true, + version: '1.0.1', + }, + }, + spec: { + version: '1.0.0', + agreements: { + eula: { + ...COMMON_CONSENT_CONTENT, + editable: false, + displayInSetting: false, + required: true, + }, + eulaNew: { + ...COMMON_CONSENT_CONTENT, + editable: false, + displayInSetting: false, + required: true, + }, + analytics: { + ...COMMON_CONSENT_CONTENT, + }, + disabledConsent: { + ...COMMON_CONSENT_CONTENT, + disabled: true, + }, + }, + }, + }), +})) + +describe('ConsentsSettings', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render proper elements', () => { + render() + expect(screen.getAllByTestId(/switch-option/)).toHaveLength(3) + }) + + it('should be disabled submit button with required options with false value', () => { + render() + expect(screen.getByTestId(BTN_SUBMIT)).toBeDisabled() + }) + + it('should be able to submit with required options with true value', () => { + render() + screen.getAllByTestId(/switch-option/).forEach((el) => { + fireEvent.click(el) + }) + expect(screen.getByTestId(BTN_SUBMIT)).not.toBeDisabled() + }) + + describe('liveEditMode', () => { + it('btn submit should not render', () => { + const { queryByTestId } = render() + expect(queryByTestId(BTN_SUBMIT)).not.toBeInTheDocument() + }) + + it('option change should call "updateUserConfigSettingsAction"', async () => { + const { queryByTestId } = render() + + await waitFor(() => { + screen.getAllByTestId(/switch-option/).forEach(async (el) => { + fireEvent.click(el) + }) + }) + + const expectedActions = [{}].fill(updateUserConfigSettings(), 0) + expect(clearStoreActions(store.getActions().slice(0, expectedActions.length))).toEqual( + clearStoreActions(expectedActions) + ) + + expect(queryByTestId(BTN_SUBMIT)).not.toBeInTheDocument() + }) + }) +}) diff --git a/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx b/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx new file mode 100644 index 0000000000..c6391a4ccc --- /dev/null +++ b/redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx @@ -0,0 +1,226 @@ +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { FormikErrors, useFormik } from 'formik' +import { has, isEmpty } from 'lodash' +import { + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiSpacer, + EuiText, + EuiButton, + EuiToolTip, + EuiForm, + EuiHorizontalRule, +} from '@elastic/eui' +import parse from 'html-react-parser' + +import { compareConsents } from 'uiSrc/utils' +import { updateUserConfigSettingsAction, userSettingsSelector } from 'uiSrc/slices/user/user-settings' + +import styles from './styles.module.scss' + +interface Values { + [key: string]: string; +} + +export interface IConsent { + defaultValue: boolean; + displayInSetting: boolean; + required: boolean; + editable: boolean; + disabled: boolean, + since: string; + title: string; + label: string; + agreementName: string; + description?: string; +} + +export interface Props { + liveEditMode?: boolean; +} + +const ConsentsSettings = ({ liveEditMode = false }: Props) => { + const [consents, setConsents] = useState([]) + const [requiredConsents, setRequiredConsents] = useState([]) + const [nonRequiredConsents, setNonRequiredConsents] = useState([]) + const [initialValues, setInitialValues] = useState({}) + const [errors, setErrors] = useState>({}) + + const { config, spec } = useSelector(userSettingsSelector) + + const dispatch = useDispatch() + + const submitIsDisabled = () => !isEmpty(errors) + + const validate = (values: any) => { + const errs: FormikErrors = {} + requiredConsents.forEach((consent) => { + if (!values[consent.agreementName]) { + errs[consent.agreementName] = consent.agreementName + } + }) + setErrors(errs) + return errs + } + + const formik = useFormik({ + initialValues, + validate: !liveEditMode ? validate : undefined, + enableReinitialize: true, + onSubmit: (values) => { + submitForm(values) + }, + }) + + useEffect(() => { + if (spec && config) { + setConsents(compareConsents(spec?.agreements, config?.agreements, liveEditMode)) + } + }, [spec, config]) + + useEffect(() => { + setRequiredConsents(consents.filter( + (consent: IConsent) => consent.required && (liveEditMode ? consent.displayInSetting : true) + )) + setNonRequiredConsents(consents.filter( + (consent: IConsent) => !consent.required && (liveEditMode ? consent.displayInSetting : true) + )) + if (consents.length) { + const values = consents.reduce( + (acc: any, cur: IConsent) => ({ ...acc, [cur.agreementName]: cur.defaultValue }), + {} + ) + + if (liveEditMode && config) { + Object.keys(values).forEach((value) => { + if (has(config.agreements, value)) { + values[value] = config?.agreements?.[value] + } + }) + } + setInitialValues(values) + } + }, [consents]) + + useEffect(() => { + !liveEditMode && formik.validateForm(initialValues) + }, [requiredConsents]) + + const onChangeAgreement = (checked: boolean, name: string) => { + formik.setFieldValue(name, checked) + liveEditMode && formik.submitForm() + } + + const submitForm = (values: any) => { + if (submitIsDisabled()) { + return + } + dispatch(updateUserConfigSettingsAction({ agreements: values })) + } + + const renderConsentOption = (consent: IConsent, withHR = false) => ( + + + + onChangeAgreement(e.target.checked, consent.agreementName)} + className={styles.switchOption} + data-testid={`switch-option-${consent.agreementName}`} + disabled={consent?.disabled} + /> + + + {parse(consent.label)} + {consent.description && ( + + {consent.description} + + )} + + + {withHR ? : } + + ) + + return ( + + {!!nonRequiredConsents.length && ( + <> + + + To improve your experience, we use third party tools in RedisInsight. All data collected + are completely anonymized, but we will not use these data for any purpose that you do + not consent to. + + + + )} + { + nonRequiredConsents + .map((consent: IConsent) => renderConsentOption(consent, nonRequiredConsents.length > 1)) + } + + {!liveEditMode && ( + <> + + While adding new plugins for Workbench, use files only from trusted authors + to avoid automatic execution of malicious code. + + + + )} + + {!!requiredConsents.length && ( + <> + + To use RedisInsight, please accept the terms and conditions: + + + + )} + + {requiredConsents.map((consent: IConsent) => renderConsentOption(consent))} + {!liveEditMode && ( + + + + + {Object.values(errors).map((err) => [ + spec?.agreements[err as string]?.requiredText, +
, + ])} + + ) : null + } + > + {}} + disabled={submitIsDisabled()} + iconType={submitIsDisabled() ? 'iInCircle' : undefined} + data-testid="btn-submit" + > + Submit + +
+
+
+ )} +
+ ) +} + +export default ConsentsSettings diff --git a/redisinsight/ui/src/components/consents-settings/ConsentsSettingsPopup/ConsentsSettingsPopup.spec.tsx b/redisinsight/ui/src/components/consents-settings/ConsentsSettingsPopup/ConsentsSettingsPopup.spec.tsx new file mode 100644 index 0000000000..cefc99a586 --- /dev/null +++ b/redisinsight/ui/src/components/consents-settings/ConsentsSettingsPopup/ConsentsSettingsPopup.spec.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' +import ConsentsSettingsPopup from './ConsentsSettingsPopup' + +describe('ConsentsSettingsPopup', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/consents-settings/ConsentsSettingsPopup/ConsentsSettingsPopup.tsx b/redisinsight/ui/src/components/consents-settings/ConsentsSettingsPopup/ConsentsSettingsPopup.tsx new file mode 100644 index 0000000000..de51924dd5 --- /dev/null +++ b/redisinsight/ui/src/components/consents-settings/ConsentsSettingsPopup/ConsentsSettingsPopup.tsx @@ -0,0 +1,61 @@ +import React, { useContext, useEffect } from 'react' +import { + EuiOverlayMask, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui' + +import { Theme } from 'uiSrc/constants' +import { ConsentsSettings } from 'uiSrc/components' +import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import darkLogo from 'uiSrc/assets/img/dark_logo.svg' +import lightLogo from 'uiSrc/assets/img/light_logo.svg' + +import styles from '../styles.module.scss' + +const ConsentsSettingsPopup = () => { + const { theme } = useContext(ThemeContext) + + useEffect(() => { + sendEventTelemetry({ + event: TelemetryEvent.CONSENT_MENU_VIEWED + }) + }, []) + + return ( + + {}} data-testid="consents-settings-popup"> + + + + + + + + + + EULA and Privacy Settings + + + + + + + + ) +} + +export default ConsentsSettingsPopup diff --git a/redisinsight/ui/src/components/consents-settings/index.ts b/redisinsight/ui/src/components/consents-settings/index.ts new file mode 100644 index 0000000000..8a5c3843fa --- /dev/null +++ b/redisinsight/ui/src/components/consents-settings/index.ts @@ -0,0 +1,4 @@ +import ConsentsSettings from './ConsentsSettings' +import ConsentsSettingsPopup from './ConsentsSettingsPopup/ConsentsSettingsPopup' + +export { ConsentsSettings, ConsentsSettingsPopup } diff --git a/redisinsight/ui/src/components/consents-settings/styles.module.scss b/redisinsight/ui/src/components/consents-settings/styles.module.scss new file mode 100644 index 0000000000..ee59691463 --- /dev/null +++ b/redisinsight/ui/src/components/consents-settings/styles.module.scss @@ -0,0 +1,47 @@ +.redisIcon { + width: 140px; + height: auto; +} + +.consentsPopup { + background-color: var(--tableRowHoverColor); + border-color: var(--tableRowHoverColor); + :global { + min-width: 744px !important; + width: 874px; + max-width: 94vw; + .euiModal__closeIcon { + display: none; + } + } + + a { + color: currentColor !important; + text-decoration: underline; + &:hover { + text-decoration: none !important; + } + } +} + +.modalHeader { + padding-bottom: 4px; +} + +.consentsPopupTitle { + color: var(--euiTextSubduedColorHover); + font-weight: 500 !important; +} + +.switchOption { + color: var(--euiTextSubduedColorHover); + font-size: 14px; + font-weight: 500; +} + +.label { + color: var(--inputTextColor) !important; + line-height: 20px !important; + font-weight: 500 !important; + font-size: 14px !important; +} diff --git a/redisinsight/ui/src/components/css.d.ts b/redisinsight/ui/src/components/css.d.ts new file mode 100644 index 0000000000..c72e8a2033 --- /dev/null +++ b/redisinsight/ui/src/components/css.d.ts @@ -0,0 +1,4 @@ +declare module '*.scss' { + const content: { [className: string]: string } + export default content +} diff --git a/redisinsight/ui/src/components/database-list-modules/DatabaseListModules.spec.tsx b/redisinsight/ui/src/components/database-list-modules/DatabaseListModules.spec.tsx new file mode 100644 index 0000000000..b79bd929a7 --- /dev/null +++ b/redisinsight/ui/src/components/database-list-modules/DatabaseListModules.spec.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { RedisDefaultModules, DATABASE_LIST_MODULES_TEXT } from 'uiSrc/slices/interfaces' +import { fireEvent, render, waitFor } from 'uiSrc/utils/test-utils' +import { RedisModuleDto } from 'apiSrc/modules/instances/dto/database-instance.dto' +import DatabaseListModules, { Props } from './DatabaseListModules' + +const mockedProps = mock() + +const modulesMock: RedisModuleDto[] = [ + { name: RedisDefaultModules.AI }, + { name: RedisDefaultModules.Bloom }, + { name: RedisDefaultModules.Gears }, + { name: RedisDefaultModules.Graph }, + { name: RedisDefaultModules.ReJSON }, + { name: RedisDefaultModules.Search }, + { name: RedisDefaultModules.TimeSeries }, +] + +describe('DatabaseListModules', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('copy module name', async () => { + const { queryByTestId } = render( + + ) + + const term = DATABASE_LIST_MODULES_TEXT[RedisDefaultModules.Search] + + const module = queryByTestId(`${term}_module`) + + await waitFor(() => { + module && fireEvent.click(module) + }) + + // queryByTestId + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/database-list-modules/DatabaseListModules.tsx b/redisinsight/ui/src/components/database-list-modules/DatabaseListModules.tsx new file mode 100644 index 0000000000..4f55b37900 --- /dev/null +++ b/redisinsight/ui/src/components/database-list-modules/DatabaseListModules.tsx @@ -0,0 +1,150 @@ +/* eslint-disable import/no-webpack-loader-syntax */ +import React, { useContext } from 'react' +import { EuiButton, EuiButtonIcon, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' +import { isNumber } from 'lodash' + +import { + RedisDefaultModules, + DATABASE_LIST_MODULES_TEXT, +} from 'uiSrc/slices/interfaces' +import { Theme } from 'uiSrc/constants' +import { getModule, truncateText } from 'uiSrc/utils' +import { ThemeContext } from 'uiSrc/contexts/themeContext' + +import RedisAILight from 'uiSrc/assets/img/modules/RedisAILight.svg' +import RedisAIDark from 'uiSrc/assets/img/modules/RedisAIDark.svg' +import RedisBloomLight from 'uiSrc/assets/img/modules/RedisBloomLight.svg' +import RedisBloomDark from 'uiSrc/assets/img/modules/RedisBloomDark.svg' +import RedisGearsLight from 'uiSrc/assets/img/modules/RedisGearsLight.svg' +import RedisGearsDark from 'uiSrc/assets/img/modules/RedisGearsDark.svg' +import RedisGraphLight from 'uiSrc/assets/img/modules/RedisGraphLight.svg' +import RedisGraphDark from 'uiSrc/assets/img/modules/RedisGraphDark.svg' +import RedisJSONLight from 'uiSrc/assets/img/modules/RedisJSONLight.svg' +import RedisJSONDark from 'uiSrc/assets/img/modules/RedisJSONDark.svg' +import RedisSearchLight from 'uiSrc/assets/img/modules/RedisSearchLight.svg' +import RedisSearchDark from 'uiSrc/assets/img/modules/RedisSearchDark.svg' +import RedisTimeSeriesLight from 'uiSrc/assets/img/modules/RedisTimeSeriesLight.svg' +import RedisTimeSeriesDark from 'uiSrc/assets/img/modules/RedisTimeSeriesDark.svg' +import UnknownLight from 'uiSrc/assets/img/modules/UnknownLight.svg' +import UnknownDark from 'uiSrc/assets/img/modules/UnknownDark.svg' +import { RedisModuleDto } from 'apiSrc/modules/instances/dto/database-instance.dto' + +import styles from './styles.module.scss' + +export interface Props { + modules: RedisModuleDto[]; + inCircle?: boolean; + dark?: boolean; + maxLength?: number; +} + +interface ITooltipProps { + icon: any; + content: any; + abbreviation?: string; +} + +const DatabaseListModules = React.memo(({ modules: modulesProp, inCircle, maxLength }: Props) => { + const modules = isNumber(maxLength) ? modulesProp.slice(0, maxLength) : modulesProp + const { theme } = useContext(ThemeContext) + + const handleCopy = (text = '') => { + navigator?.clipboard?.writeText(text) + } + + const modulesDefaultInit = { + [RedisDefaultModules.AI]: { + iconDark: RedisAIDark, + iconLight: RedisAILight, + text: DATABASE_LIST_MODULES_TEXT[RedisDefaultModules.AI], + }, + [RedisDefaultModules.Bloom]: { + iconDark: RedisBloomDark, + iconLight: RedisBloomLight, + text: DATABASE_LIST_MODULES_TEXT[RedisDefaultModules.Bloom], + }, + [RedisDefaultModules.Gears]: { + iconDark: RedisGearsDark, + iconLight: RedisGearsLight, + text: DATABASE_LIST_MODULES_TEXT[RedisDefaultModules.Gears], + }, + [RedisDefaultModules.Graph]: { + iconDark: RedisGraphDark, + iconLight: RedisGraphLight, + text: DATABASE_LIST_MODULES_TEXT[RedisDefaultModules.Graph], + }, + [RedisDefaultModules.ReJSON]: { + iconDark: RedisJSONDark, + iconLight: RedisJSONLight, + text: DATABASE_LIST_MODULES_TEXT[RedisDefaultModules.ReJSON], + }, + [RedisDefaultModules.Search]: { + iconDark: RedisSearchDark, + iconLight: RedisSearchLight, + text: DATABASE_LIST_MODULES_TEXT[RedisDefaultModules.Search], + }, + [RedisDefaultModules.TimeSeries]: { + iconDark: RedisTimeSeriesDark, + iconLight: RedisTimeSeriesLight, + text: DATABASE_LIST_MODULES_TEXT[RedisDefaultModules.TimeSeries], + }, + } + + const Tooltip = ({ icon, content, abbreviation }: ITooltipProps) => ( + <> + + {icon ? ( + handleCopy(content)} + data-testid={`${content}_module`} + aria-labelledby={`${content}_module`} + /> + ) : ( + handleCopy(content)} + data-testid={`${content}_module`} + aria-labelledby={`${content}_module`} + > + {abbreviation} + + )} + + + ) + + const modulesRender = modules?.map(({ name: propName, semanticVersion = '', version = '' }) => { + const moduleName = modulesDefaultInit[propName]?.text || propName + + const { abbreviation = '', name = moduleName } = getModule(moduleName) + + const moduleAlias = truncateText(name, 50) + const content = `${moduleAlias}${semanticVersion || version ? ` v. ${semanticVersion || version}` : ''}` + let icon = modulesDefaultInit[propName]?.[theme === Theme.Dark ? 'iconDark' : 'iconLight'] + + if (!icon && !abbreviation) { + icon = theme === Theme.Dark ? UnknownDark : UnknownLight + } + + return ( + + ) + }) + + return <>{modulesRender} +}) + +export default DatabaseListModules diff --git a/redisinsight/ui/src/components/database-list-modules/styles.module.scss b/redisinsight/ui/src/components/database-list-modules/styles.module.scss new file mode 100644 index 0000000000..27cb3eee4b --- /dev/null +++ b/redisinsight/ui/src/components/database-list-modules/styles.module.scss @@ -0,0 +1,26 @@ +.circle { + background-color: var(--moduleBackgroundColor); + border: none !important; + width: 28px !important; + height: 28px !important; + border-radius: 14px !important; + + &:hover, + &:focus, + &:focus-within { + background-color: var(--moduleBackgroundColor) !important; + } +} + +button.icon { + width: 28px !important; + min-width: 28px !important; + max-width: 28px !important; + * { + padding: 0 !important; + } +} + +.anchorCircleIcon { + margin-right: 14px; +} diff --git a/redisinsight/ui/src/components/database-list-options/DatabaseListOptions.spec.tsx b/redisinsight/ui/src/components/database-list-options/DatabaseListOptions.spec.tsx new file mode 100644 index 0000000000..759c1ffd12 --- /dev/null +++ b/redisinsight/ui/src/components/database-list-options/DatabaseListOptions.spec.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' +import DatabaseListOptions from './DatabaseListOptions' + +const optionsMock: Partial = { + enabledDataPersistence: true, + persistencePolicy: 'aof-every-write', + enabledRedisFlash: false, + enabledReplication: false, + enabledBackup: false, + enabledActiveActive: false, + enabledClustering: false, + isReplicaDestination: false, + isReplicaSource: false, +} + +describe('DatabaseListOptions', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/database-list-options/DatabaseListOptions.tsx b/redisinsight/ui/src/components/database-list-options/DatabaseListOptions.tsx new file mode 100644 index 0000000000..db19c06eca --- /dev/null +++ b/redisinsight/ui/src/components/database-list-options/DatabaseListOptions.tsx @@ -0,0 +1,130 @@ +import React, { useContext } from 'react' +import { isString } from 'lodash' +import { EuiButtonIcon, EuiToolTip, IconType } from '@elastic/eui' + +import { + AddRedisClusterDatabaseOptions, + DATABASE_LIST_OPTIONS_TEXT, + PersistencePolicy, +} from 'uiSrc/slices/interfaces' + +import { Theme } from 'uiSrc/constants' +import { ThemeContext } from 'uiSrc/contexts/themeContext' + +import ActiveActiveDark from 'uiSrc/assets/img/options/Active-ActiveDark.svg' +import ActiveActiveLight from 'uiSrc/assets/img/options/Active-ActiveLight.svg' +import RedisOnFlashDark from 'uiSrc/assets/img/options/RedisOnFlashDark.svg' +import RedisOnFlashLight from 'uiSrc/assets/img/options/RedisOnFlashLight.svg' + +import styles from './styles.module.scss' + +interface Props { + options: Partial; +} + +interface ITooltipProps { + content: string; + index: number; + value: any; + icon: IconType; +} + +const DatabaseListOptions = ({ options }: Props) => { + const { theme } = useContext(ThemeContext) + + const handleCopy = (text = '') => { + navigator.clipboard.writeText(text) + } + + const OPTIONS_CONTENT = { + [AddRedisClusterDatabaseOptions.ActiveActive]: { + icon: theme === Theme.Dark ? ActiveActiveDark : ActiveActiveLight, + text: DATABASE_LIST_OPTIONS_TEXT[AddRedisClusterDatabaseOptions.ActiveActive] + }, + [AddRedisClusterDatabaseOptions.Backup]: { + text: DATABASE_LIST_OPTIONS_TEXT[AddRedisClusterDatabaseOptions.Backup], + }, + + [AddRedisClusterDatabaseOptions.Clustering]: { + text: DATABASE_LIST_OPTIONS_TEXT[AddRedisClusterDatabaseOptions.Clustering] + }, + [AddRedisClusterDatabaseOptions.PersistencePolicy]: { + text: DATABASE_LIST_OPTIONS_TEXT[AddRedisClusterDatabaseOptions.PersistencePolicy] + }, + [AddRedisClusterDatabaseOptions.Flash]: { + icon: theme === Theme.Dark ? RedisOnFlashDark : RedisOnFlashLight, + text: DATABASE_LIST_OPTIONS_TEXT[AddRedisClusterDatabaseOptions.Flash] + }, + [AddRedisClusterDatabaseOptions.Replication]: { + text: DATABASE_LIST_OPTIONS_TEXT[AddRedisClusterDatabaseOptions.Replication] + }, + [AddRedisClusterDatabaseOptions.ReplicaDestination]: { + text: DATABASE_LIST_OPTIONS_TEXT[AddRedisClusterDatabaseOptions.ReplicaDestination] + }, + [AddRedisClusterDatabaseOptions.ReplicaSource]: { + text: DATABASE_LIST_OPTIONS_TEXT[AddRedisClusterDatabaseOptions.ReplicaSource] + }, + } + + const Tooltip = ({ content: contentProp, icon, index, value }: ITooltipProps) => ( + <> + {contentProp ? ( + + {icon ? ( + handleCopy(contentProp)} + aria-labelledby={`${contentProp}_module`} + /> + ) : ( +
handleCopy(contentProp)} + onKeyDown={() => ({})} + role="presentation" + > + {contentProp.match(/\b(\w)/g)?.join('')} +
+ )} +
+ ) : null} + + ) + + const optionsRender = Object.entries(options) + ?.sort(([option]) => { + if (OPTIONS_CONTENT[option]?.icon === undefined) { + return -1 + } + return 0 + }) + ?.map( + ([option, value]: any, index: number) => { + if (value && value !== PersistencePolicy.none) { + return ( + + ) + } + return null + } + ) + + return
{optionsRender}
+} + +export default DatabaseListOptions diff --git a/redisinsight/ui/src/components/database-list-options/styles.module.scss b/redisinsight/ui/src/components/database-list-options/styles.module.scss new file mode 100644 index 0000000000..e9f1151fe8 --- /dev/null +++ b/redisinsight/ui/src/components/database-list-options/styles.module.scss @@ -0,0 +1,85 @@ +.options { + padding-left: 7px; + display: flex; + align-items: center; +} + +:global { + .options_icon { + display: inline-block; + width: 28px; + height: 28px; + border-radius: 14px; + cursor: pointer; + line-height: 24px; + text-align: center; + text-transform: uppercase; + margin-left: -7px; + color: #fff; + + &:hover { + transform: translateY(-1px); + } + &:active { + transform: translateY(1px); + } + } + + .option_icon_0 { + background-color: #293152; + } + .option_icon_1 { + background-color: #323e6c; + } + .option_icon_2 { + background-color: #465282; + } + .option_icon_3 { + background-color: #606c98; + } + .option_icon_4 { + background-color: #737fa8; + } + .option_icon_5 { + background-color: #8f99bc; + color: #202020; + } + .option_icon_6 { + background-color: #adb5d3; + color: #202020; + } + .option_icon_7 { + background-color: #cdd4ea; + color: #202020; + } + .theme_LIGHT { + .option_icon_0 { + background-color: #587AB2; + } + .option_icon_1 { + background-color: #6A8BC1; + } + .option_icon_2 { + background-color: #97B4E3; + } + .option_icon_3 { + background-color: #ADC5ED; + } + .option_icon_4 { + background-color: #C6D8F7; + } + .option_icon_5 { + background-color: #DEEAFF; + } + .option_icon_6 { + background-color: #EAF1FF; + } + .option_icon_7 { + background-color: #EFF4FF; + } + .option_icon_2, .option_icon_3, .option_icon_4, + .option_icon_5, .option_icon_6, .option_icon_7 { + color: #1A3091; + } + } +} diff --git a/redisinsight/ui/src/components/database-overview/DatabaseOverview.spec.tsx b/redisinsight/ui/src/components/database-overview/DatabaseOverview.spec.tsx new file mode 100644 index 0000000000..2b77b6c0f8 --- /dev/null +++ b/redisinsight/ui/src/components/database-overview/DatabaseOverview.spec.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' + +import { getOverviewItems } from './components/OverviewItems' +import DatabaseOverview from './DatabaseOverview' + +const overviewItemsMock = getOverviewItems({ + theme: 'DARK', + items: { + usedMemory: 100, + totalKeys: 5000, + connectedClients: 1, + cpuUsagePercentage: 0.23, + networkInKbps: 3, + networkOutKbps: 5, + opsPerSecond: 10 + } +}) + +describe('DatabaseOverview', () => { + it('should render', () => { + expect(render( + + )).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/database-overview/DatabaseOverview.tsx b/redisinsight/ui/src/components/database-overview/DatabaseOverview.tsx new file mode 100644 index 0000000000..d027f06b7f --- /dev/null +++ b/redisinsight/ui/src/components/database-overview/DatabaseOverview.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import cx from 'classnames' +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiToolTip } from '@elastic/eui' + +import styles from './styles.module.scss' + +interface Props { + maxLength: number; + items: any[] +} + +const DatabaseOverview = ({ maxLength, items }: Props) => ( + + {items.slice(0, maxLength).map((overviewItem) => ( + + + + {overviewItem.icon && ( + + + + )} + + { overviewItem.content } + + + + + ))} + +) + +export default DatabaseOverview diff --git a/redisinsight/ui/src/components/database-overview/components/OverviewItems.tsx b/redisinsight/ui/src/components/database-overview/components/OverviewItems.tsx new file mode 100644 index 0000000000..a08f9558ab --- /dev/null +++ b/redisinsight/ui/src/components/database-overview/components/OverviewItems.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLoadingSpinner } from '@elastic/eui' + +import { formatBytes, Nullable, truncateNumberToRange, truncatePercentage } from 'uiSrc/utils' +import { Theme } from 'uiSrc/constants' +import { numberWithSpaces } from 'uiSrc/utils/numbers' +import { + KeyTipIcon, + KeyDarkIcon, + KeyLightIcon, + MemoryDarkIcon, + MemoryLightIcon, + MeasureTipIcon, + MeasureDarkIcon, + MeasureLightIcon, + TimeDarkIcon, + TimeLightIcon, + UserDarkIcon, + UserLightIcon, + UserTipIcon, + InputLightIcon, + InputTipIcon, + OutputLightIcon, + OutputTipIcon, +} from 'uiSrc/components/database-overview/components/icons' + +import styles from 'uiSrc/components/database-overview/styles.module.scss' + +interface Props { + theme: string; + items: { + usedMemory: Nullable; + totalKeys: Nullable; + connectedClients: Nullable; + opsPerSecond: Nullable; + networkInKbps: Nullable; + networkOutKbps: Nullable; + cpuUsagePercentage: Nullable; + }; +} + +export const getOverviewItems = ({ theme, items }: Props) => { + const { + usedMemory, + totalKeys, + connectedClients = 0, + cpuUsagePercentage, + opsPerSecond, + networkInKbps, + networkOutKbps + } = items + + const commandsPerSecTooltip = [ + { + id: 'commands-per-sec-tip', + title: 'Commands/Sec', + icon: theme === Theme.Dark ? MeasureTipIcon : MeasureLightIcon, + value: opsPerSecond + }, + { + id: 'network-input-tip', + title: 'Network Input', + icon: theme === Theme.Dark ? InputTipIcon : InputLightIcon, + value: `${networkInKbps} kbps` + }, + { + id: 'network-output-tip', + title: 'Network Output', + icon: theme === Theme.Dark ? OutputTipIcon : OutputLightIcon, + value: `${networkOutKbps} kbps` + } + ] + + const getConnectedClient = (connectedClients: number = 0) => + (Number.isInteger(connectedClients) ? connectedClients : `~${Math.round(connectedClients)}`) + + return [ + { + id: 'overview-cpu', + tooltip: { + title: 'CPU', + content: cpuUsagePercentage === null ? 'Calculating CPU in progress' : `${truncatePercentage(cpuUsagePercentage, 4)} %` + }, + className: styles.cpuWrapper, + icon: cpuUsagePercentage !== null ? (theme === Theme.Dark ? TimeDarkIcon : TimeLightIcon) : null, + content: cpuUsagePercentage === null ? ( + <> +
+ + Calculating... +
+ + ) : `${truncatePercentage(cpuUsagePercentage, 2)} %`, + }, + { + id: 'overview-commands-sec', + tooltip: { + content: commandsPerSecTooltip.map((tooltipItem) => ( + + + + + + {tooltipItem.value} + + + {tooltipItem.title} + + + )) + }, + icon: theme === Theme.Dark ? MeasureDarkIcon : MeasureLightIcon, + content: opsPerSecond, + }, + { + id: 'overview-total-memory', + tooltip: { + title: 'Total Memory', + content: formatBytes(usedMemory || 0, 3) + }, + icon: theme === Theme.Dark ? MemoryDarkIcon : MemoryLightIcon, + content: formatBytes(usedMemory || 0, 0), + }, + { + id: 'overview-total-keys', + tooltip: { + title: 'Total Keys', + content: numberWithSpaces(totalKeys || 0) + }, + icon: theme === Theme.Dark ? KeyDarkIcon : KeyLightIcon, + tooltipIcon: theme === Theme.Dark ? KeyTipIcon : KeyLightIcon, + content: truncateNumberToRange(totalKeys || 0), + }, + { + id: 'overview-connected-clients', + tooltip: { + title: 'Connected Clients', + content: getConnectedClient(connectedClients ?? 0) + }, + icon: theme === Theme.Dark ? UserDarkIcon : UserLightIcon, + tooltipIcon: theme === Theme.Dark ? UserTipIcon : UserLightIcon, + content: getConnectedClient(connectedClients ?? 0), + } + ] +} diff --git a/redisinsight/ui/src/components/database-overview/components/icons.ts b/redisinsight/ui/src/components/database-overview/components/icons.ts new file mode 100644 index 0000000000..2ee1002833 --- /dev/null +++ b/redisinsight/ui/src/components/database-overview/components/icons.ts @@ -0,0 +1,39 @@ +import KeyDarkIcon from 'uiSrc/assets/img/overview/key_dark.svg' +import KeyTipIcon from 'uiSrc/assets/img/overview/key_tip.svg' +import KeyLightIcon from 'uiSrc/assets/img/overview/key_light.svg' +import MemoryDarkIcon from 'uiSrc/assets/img/overview/memory_dark.svg' +import MemoryLightIcon from 'uiSrc/assets/img/overview/memory_light.svg' +import MeasureLightIcon from 'uiSrc/assets/img/overview/measure_light.svg' +import MeasureDarkIcon from 'uiSrc/assets/img/overview/measure_dark.svg' +import MeasureTipIcon from 'uiSrc/assets/img/overview/measure_tip.svg' +import TimeLightIcon from 'uiSrc/assets/img/overview/time_light.svg' +import TimeDarkIcon from 'uiSrc/assets/img/overview/time_dark.svg' +import TimeTipIcon from 'uiSrc/assets/img/overview/time_tip.svg' +import UserDarkIcon from 'uiSrc/assets/img/overview/user_dark.svg' +import UserLightIcon from 'uiSrc/assets/img/overview/user_light.svg' +import UserTipIcon from 'uiSrc/assets/img/overview/user_tip.svg' +import InputTipIcon from 'uiSrc/assets/img/overview/input_tip.svg' +import InputLightIcon from 'uiSrc/assets/img/overview/input_light.svg' +import OutputTipIcon from 'uiSrc/assets/img/overview/output_tip.svg' +import OutputLightIcon from 'uiSrc/assets/img/overview/output_light.svg' + +export { + KeyDarkIcon, + KeyTipIcon, + KeyLightIcon, + MemoryDarkIcon, + MemoryLightIcon, + MeasureLightIcon, + MeasureDarkIcon, + MeasureTipIcon, + TimeLightIcon, + TimeDarkIcon, + TimeTipIcon, + UserDarkIcon, + UserLightIcon, + UserTipIcon, + InputTipIcon, + InputLightIcon, + OutputTipIcon, + OutputLightIcon +} diff --git a/redisinsight/ui/src/components/database-overview/styles.module.scss b/redisinsight/ui/src/components/database-overview/styles.module.scss new file mode 100644 index 0000000000..106d1d48be --- /dev/null +++ b/redisinsight/ui/src/components/database-overview/styles.module.scss @@ -0,0 +1,72 @@ +.overviewItem { + padding: 8px 20px; + min-width: 116px; + + @media only screen and (max-width: 1024px) { + padding: 8px 10px; + min-width: 96px; + } + &:not(:last-child) { + border-right: 1px solid var(--tableLightestBorderColor); + } + + @media only screen and (max-width: 1124px) { + border-right: 1px solid var(--tableLightestBorderColor); + } +} +.icon { + margin-right: 6px; + width: auto !important; + height: 18px !important; + max-width: 22px; +} + +.tooltip { + max-width: 372px; +} + +.commandsPerSecTip { + margin-bottom: 8px; + .moreInfoOverviewIcon { + margin-right: 8px; + width: auto !important; + max-width: 20px; + height: 18px !important; + } + + .moreInfoOverviewContent { + margin-right: 6px; + font-weight: 500; + font-size: 13px; + } + + .moreInfoOverviewTitle { + margin-right: 6px; + font-size: 12px; + } +} + +.calculationWrapper { + display: flex; + align-items: center; + min-width: 134px; +} + +.cpuWrapper { + min-width: 132px; + + @media only screen and (max-width: 1024px) { + min-width: 114px; + } +} + +.calculation { + font-size: 13px; + font-weight: 500; + margin-left: 8px; +} + +.spinner { + width: 18px !important; + height: 18px !important; +} diff --git a/redisinsight/ui/src/components/divider/Divider.spec.tsx b/redisinsight/ui/src/components/divider/Divider.spec.tsx new file mode 100644 index 0000000000..1db4931603 --- /dev/null +++ b/redisinsight/ui/src/components/divider/Divider.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import Divider, { Props } from './Divider' + +const mockedProps = mock() + +describe('Divider', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/divider/Divider.tsx b/redisinsight/ui/src/components/divider/Divider.tsx new file mode 100644 index 0000000000..70d0ac1de2 --- /dev/null +++ b/redisinsight/ui/src/components/divider/Divider.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import cx from 'classnames' + +import styles from './styles.module.scss' + +export interface Props { + color?: string + orientation?: 'horizontal' | 'vertical', + variant? : 'fullWidth' | 'middle' | 'half'; + className?: string; +} + +const Divider = ({ orientation, variant, className, color }: Props) => ( +
+
+
+) + +export default Divider diff --git a/redisinsight/ui/src/components/divider/styles.module.scss b/redisinsight/ui/src/components/divider/styles.module.scss new file mode 100644 index 0000000000..945bde0eeb --- /dev/null +++ b/redisinsight/ui/src/components/divider/styles.module.scss @@ -0,0 +1,42 @@ +.divider { + display: flex; + align-items: center; + justify-content: center; + margin: 8px 0; + hr { + border: none; + height: 1px; + width: 100%; + background-color: var(--tableLightestBorderColor) + } + &-vertical { + height: auto; + margin: 0 8px; + hr { + height: 100%; + width: 1px; + } + } + + &-middle { + margin: 8px 6px; + &.divider-vertical { + margin: 6px 8px; + } + } + + &-half { + margin: 8px 0; + hr { + width: 50%; + } + &.divider-vertical { + margin: 0 8px; + hr { + width: 1px; + height: 50%; + } + } + } +} + diff --git a/redisinsight/ui/src/components/field-message/FieldMessage.spec.tsx b/redisinsight/ui/src/components/field-message/FieldMessage.spec.tsx new file mode 100644 index 0000000000..cf7b5ba42c --- /dev/null +++ b/redisinsight/ui/src/components/field-message/FieldMessage.spec.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import FieldMessage, { Props } from './FieldMessage' + +const mockedProps = mock() + +describe('FieldMessage', () => { + it('should render', () => { + const message = 'Error Message' + expect(render({message})).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/field-message/FieldMessage.tsx b/redisinsight/ui/src/components/field-message/FieldMessage.tsx new file mode 100644 index 0000000000..caa8ac2810 --- /dev/null +++ b/redisinsight/ui/src/components/field-message/FieldMessage.tsx @@ -0,0 +1,46 @@ +import React, { Ref, useEffect, useRef } from 'react' +import cx from 'classnames' +import { EuiIcon, EuiTextColor } from '@elastic/eui' + +import styles from './styles.module.scss' + +type Colors = 'default' | 'secondary' | 'accent' | 'warning' | 'danger' | 'subdued' | 'ghost' +export interface Props { + children: React.ReactElement | string; + color?: Colors, + scrollViewOnAppear?: boolean; + icon?: string, + testID?: string, +} + +const FieldMessage = ({ children, color, testID, icon, scrollViewOnAppear }: Props) => { + const divRef: Ref = useRef(null) + + useEffect(() => { + // componentDidMount + if (scrollViewOnAppear) { + divRef?.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'end', + }) + } + }, []) + + return ( +
+ {icon && ( + + )} + + {children} + +
+ ) +} + +export default FieldMessage diff --git a/redisinsight/ui/src/components/field-message/styles.module.scss b/redisinsight/ui/src/components/field-message/styles.module.scss new file mode 100644 index 0000000000..1c2c21a201 --- /dev/null +++ b/redisinsight/ui/src/components/field-message/styles.module.scss @@ -0,0 +1,12 @@ +.container { + padding: 4px 0; + display: flex; + flex: 1; +} +.icon { + margin-right: 4px; +} +.message { + font: normal normal normal 12px/16px Graphik, sans-serif; + letter-spacing: 0.43px; +} diff --git a/redisinsight/ui/src/components/group-badge/GroupBadge.tsx b/redisinsight/ui/src/components/group-badge/GroupBadge.tsx new file mode 100644 index 0000000000..94fbd92731 --- /dev/null +++ b/redisinsight/ui/src/components/group-badge/GroupBadge.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { EuiBadge, EuiText } from '@elastic/eui' +import { CommandGroup, KeyTypes, GROUP_TYPES_COLORS, GROUP_TYPES_DISPLAY } from 'uiSrc/constants' + +export interface Props { + type: KeyTypes | CommandGroup; + name?: string, + className?: string +} + +const GroupBadge = ({ type, name = '', className = '' }: Props) => ( + + + {GROUP_TYPES_DISPLAY[type] ?? type} + + +) + +export default GroupBadge diff --git a/redisinsight/ui/src/components/index.ts b/redisinsight/ui/src/components/index.ts new file mode 100644 index 0000000000..966ed7f69a --- /dev/null +++ b/redisinsight/ui/src/components/index.ts @@ -0,0 +1,34 @@ +import NavigationMenu from './navigation-menu/NavigationMenu' +import PageHeader from './page-header/PageHeader' +import GroupBadge from './group-badge/GroupBadge' +import ActionBar from './action-bar/ActionBar' +import Notifications from './notifications/Notifications' +import DatabaseListModules from './database-list-modules/DatabaseListModules' +import DatabaseListOptions from './database-list-options/DatabaseListOptions' +import DatabaseOverview from './database-overview/DatabaseOverview' +import InputFieldSentinel from './input-field-sentinel/InputFieldSentinel' +import PageBreadcrumbs from './page-breadcrumbs/PageBreadcrumbs' +import ContentEditable from './ContentEditable' +import Config from './config' +import AdvancedSettings from './advanced-settings/AdvancedSettings' +import { ConsentsSettings, ConsentsSettingsPopup } from './consents-settings' +import KeyboardShortcut from './keyboard-shortcut/KeyboardShortcut' + +export { + NavigationMenu, + PageHeader, + GroupBadge, + ActionBar, + Notifications, + DatabaseListModules, + DatabaseListOptions, + DatabaseOverview, + InputFieldSentinel, + PageBreadcrumbs, + Config, + ContentEditable, + ConsentsSettings, + ConsentsSettingsPopup, + AdvancedSettings, + KeyboardShortcut +} diff --git a/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.spec.tsx b/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.spec.tsx new file mode 100644 index 0000000000..7c1d641bbb --- /dev/null +++ b/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import { validateScoreNumber } from 'uiSrc/utils' +import InlineItemEditor, { Props } from './InlineItemEditor' + +const mockedProps = mock() +const INLINE_ITEM_EDITOR = 'inline-item-editor' + +describe('InlineItemEditor', () => { + it('should render', () => { + expect( + render( + + ) + ).toBeTruthy() + }) + + it('should change value properly', () => { + render() + fireEvent.change(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value: 'val' } }) + expect(screen.getByTestId(INLINE_ITEM_EDITOR)).toHaveValue('val') + }) + + it('should change value properly with validation', () => { + render() + fireEvent.change(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value: 'val123' } }) + expect(screen.getByTestId(INLINE_ITEM_EDITOR)).toHaveValue(validateScoreNumber('val123')) + }) +}) diff --git a/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.tsx b/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.tsx new file mode 100644 index 0000000000..358e51e99e --- /dev/null +++ b/redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.tsx @@ -0,0 +1,196 @@ +import React, { + ChangeEvent, + FormEvent, + Ref, + useEffect, + useRef, + useState, +} from 'react' +import { capitalize } from 'lodash' +import cx from 'classnames' +import { + EuiButtonIcon, + EuiFieldText, + EuiFlexItem, + EuiForm, + EuiOutsideClickDetector, + EuiFocusTrap, + EuiWindowEvent, +} from '@elastic/eui' +import { IconSize } from '@elastic/eui/src/components/icon/icon' +import styles from './styles.module.scss' + +type Positions = 'top' | 'bottom' | 'left' | 'right' + +export interface Props { + onDecline: (event?: React.MouseEvent) => void; + onApply: (value: string) => void; + onChange?: (value: string) => void; + fieldName?: string; + initialValue?: string; + placeholder?: string; + controlsPosition?: Positions; + maxLength?: number; + expandable?: boolean; + isLoading?: boolean; + isDisabled?: boolean; + isInvalid?: boolean; + disableEmpty?: boolean; + disableByValidation?: (value: string) => boolean; + children?: React.ReactElement; + validation?: (value: string) => string; + declineOnUnmount?: boolean; + iconSize?: IconSize; + viewChildrenMode?: boolean +} + +const InlineItemEditor = (props: Props) => { + const { + initialValue = '', + placeholder = '', + controlsPosition = 'bottom', + onDecline, + onApply, + onChange, + fieldName, + maxLength, + children, + expandable, + isLoading, + isInvalid, + disableEmpty, + disableByValidation, + validation, + declineOnUnmount = true, + viewChildrenMode, + iconSize, + isDisabled, + } = props + const containerEl: Ref = useRef(null) + const [value, setValue] = useState(initialValue) + const [isError, setIsError] = useState(false) + + useEffect(() => + // componentWillUnmount + () => { + declineOnUnmount && onDecline() + }, + []) + + const handleChangeValue = (e: ChangeEvent) => { + const newValue = e.target.value + + if (disableByValidation) { + setIsError(disableByValidation(newValue)) + } + + if (validation) { + const validatedValue = validation(newValue) + setValue(validatedValue) + onChange?.(validatedValue) + } else { + setValue(newValue) + onChange?.(newValue) + } + } + + const handleClickOutside = (event: any) => { + if (!containerEl?.current?.contains(event.target)) { + if (!isLoading) { + onDecline(event) + } else { + event.stopPropagation() + event.preventDefault() + } + } + } + + const handleOnEsc = (e: KeyboardEvent) => { + if (e.code.toLowerCase() === 'escape' || e.keyCode === 27) { + e.stopPropagation() + onDecline() + } + } + + const handleFormSubmit = (event: FormEvent): void => { + event.preventDefault() + onApply(value) + } + + const isDisabledApply = (): boolean => + !!(isLoading || isError || isDisabled || (disableEmpty && !value.length)) + + return ( + <> + {viewChildrenMode + ? children : ( + +
+ + + + + {children || ( + <> + + {expandable && ( +

{value}

+ )} + + )} +
+
+ + +
+
+
+
+
+ )} + + ) +} + +export default InlineItemEditor diff --git a/redisinsight/ui/src/components/inline-item-editor/styles.module.scss b/redisinsight/ui/src/components/inline-item-editor/styles.module.scss new file mode 100644 index 0000000000..499824c5c6 --- /dev/null +++ b/redisinsight/ui/src/components/inline-item-editor/styles.module.scss @@ -0,0 +1,75 @@ +.container { + max-width: 100%; + + :global(.euiFormControlLayout) { + max-width: 100% !important; + } +} + +.field { + min-width: 110px; + max-width: 100% !important; + box-shadow: 0 3px 3px var(--controlsBoxShadowColor) !important; + height: 33px !important; + border: 1px solid var(--controlsBoxShadowColor) !important; +} + +.controls { + position: absolute; + background-color: var(--euiColorLightestShade); + width: 80px; + height: 33px; + + z-index: 1; + + :global(.euiButtonIcon) { + width: 50% !important; + height: 100% !important; + } +} + +.controlsBottom { + top: 100%; + right: 0; + border-radius: 0 0 10px 10px; + box-shadow: 0 3px 3px var(--controlsBoxShadowColor); +} + +.controlsTop { + bottom: 100%; + right: 0; + border-radius: 10px 10px 0 0; + box-shadow: 0 -3px 3px var(--controlsBoxShadowColor); +} + +.controlsRight { + top: 0; + left: 100%; + border-radius: 0 10px 10px 0; + box-shadow: 0 3px 3px var(--controlsBoxShadowColor); +} + +.controlsLeft { + top: 0; + right: 100%; + border-radius: 10px 0 0 10px; + box-shadow: 0 3px 3px var(--controlsBoxShadowColor); +} + +.declineBtn:hover { + color: var(--euiColorColorDanger) !important; +} + +.applyBtn:hover { + color: var(--euiColorPrimary) !important; +} + +.keyHiddenText { + display: inline-block; + visibility: hidden; + height: 1px; + overflow: hidden; + max-width: 100%; + margin-right: 80px; + word-break: break-all; +} diff --git a/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.spec.tsx b/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.spec.tsx new file mode 100644 index 0000000000..ca8f2aa991 --- /dev/null +++ b/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import InputFieldSentinel, { Props, SentinelInputFieldType } from './InputFieldSentinel' + +const mockedProps = mock() + +const inputTextTestId = 'sentinel-input' +const inputPasswordTestId = 'sentinel-input-password' +const inputNumberTestId = 'sentinel-input-number' + +describe('InputFieldSentinel', () => { + it('should render simple fieldText', () => { + expect( + render() + ).toBeTruthy() + }) + + it('should change simple fieldText properly', () => { + render() + fireEvent.change(screen.getByTestId(inputTextTestId), { target: { value: 'val' } }) + expect(screen.getByTestId(inputTextTestId)).toHaveValue('val') + }) + + it('should render Password field', () => { + render() + expect(screen.getByTestId(inputPasswordTestId)).toBeInTheDocument() + }) + + it('should change Password field properly', () => { + render() + fireEvent.change(screen.getByTestId(inputPasswordTestId), { target: { value: 'val' } }) + expect(screen.getByTestId(inputPasswordTestId)).toHaveValue('val') + }) + + it('should render Number field', () => { + render() + expect(screen.getByTestId(inputNumberTestId)).toBeInTheDocument() + }) + + it('should change Number field properly', () => { + render() + fireEvent.change(screen.getByTestId(inputNumberTestId), { target: { value: 'val13' } }) + expect(screen.getByTestId(inputNumberTestId)).toHaveValue('13') + }) +}) diff --git a/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.tsx b/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.tsx new file mode 100644 index 0000000000..3837ef3b9c --- /dev/null +++ b/redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.tsx @@ -0,0 +1,97 @@ +import { EuiFieldText, EuiFieldPassword, EuiIcon, EuiFieldNumber } from '@elastic/eui' +import { omit } from 'lodash' +import React, { useState } from 'react' +import cx from 'classnames' +import { useDebouncedEffect } from 'uiSrc/services' +import { validateDatabaseNumber } from 'uiSrc/utils' + +import styles from './styles.module.scss' + +export enum SentinelInputFieldType { + Text = 'text', + Password = 'password', + Number = 'number', +} + +export interface Props { + name?: string; + value?: string; + placeholder?: string; + inputType?: SentinelInputFieldType; + isText?: boolean; + isNumber?: boolean; + maxLength?: number; + min?: number; + max?: number; + isInvalid?: boolean; + disabled?: boolean; + className?: string; + append?: React.ReactElement; + onChangedInput: (name: string, value: string) => void; +} + +const InputFieldSentinel = (props: Props) => { + const { + name = '', + value: valueProp = '', + inputType = SentinelInputFieldType.Text, + isInvalid: isInvalidProp = false, + onChangedInput, + } = props + + const clearProp = omit(props, 'inputType') + + const [value, setValue] = useState(valueProp) + const [isInvalid, setIsInvalid] = useState(isInvalidProp) + + const handleChange = (value: string) => { + setValue(value) + isInvalid && setIsInvalid(false) + } + + useDebouncedEffect(() => onChangedInput(name, value), 200, [value]) + + return ( + <> + {inputType === SentinelInputFieldType.Text && ( + handleChange(e.target?.value)} + data-testid="sentinel-input" + /> + )} + {inputType === SentinelInputFieldType.Password && ( + handleChange(e.target?.value)} + data-testid="sentinel-input-password" + /> + )} + {inputType === SentinelInputFieldType.Number && ( + handleChange(validateDatabaseNumber(e.target?.value))} + data-testid="sentinel-input-number" + /> + )} + {isInvalid && ( + + )} + + ) +} + +export default InputFieldSentinel diff --git a/redisinsight/ui/src/components/input-field-sentinel/styles.module.scss b/redisinsight/ui/src/components/input-field-sentinel/styles.module.scss new file mode 100644 index 0000000000..9516e07303 --- /dev/null +++ b/redisinsight/ui/src/components/input-field-sentinel/styles.module.scss @@ -0,0 +1,5 @@ +.inputInvalidIcon { + position: absolute; + top: calc(50% - 9px); + right: 10px; +} diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx new file mode 100644 index 0000000000..752220310b --- /dev/null +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx @@ -0,0 +1,37 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' +import InstanceHeader, { Props } from './InstanceHeader' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/services', () => ({ + ...jest.requireActual('uiSrc/services'), + sessionStorageService: { + set: jest.fn(), + get: jest.fn(), + }, +})) + +describe('InstanceHeader', () => { + it('should render', () => { + // connectedInstanceSelector.mockImplementation(() => ({ + // id: '123', + // connectionType: 'CLUSTER', + // })); + + // const sendCliClusterActionMock = jest.fn(); + + // sendCliClusterCommandAction.mockImplementation(() => sendCliClusterActionMock); + + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx new file mode 100644 index 0000000000..22b015779d --- /dev/null +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx @@ -0,0 +1,247 @@ +import React, { useContext, useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' +import parse from 'html-react-parser' +import { capitalize } from 'lodash' +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPopover } from '@elastic/eui' + +import { DatabaseOverview } from 'uiSrc/components' +import { BreadcrumbsLinks, BrowserPageOptions } from 'uiSrc/constants/breadcrumbs' +import { + connectedInstanceOverviewSelector, + connectedInstanceSelector, + getDatabaseConfigInfoAction +} from 'uiSrc/slices/instances' +import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { CONNECTION_TYPE_DISPLAY } from 'uiSrc/slices/interfaces' +import { getDbIndex, getModule, truncateText } from 'uiSrc/utils' +import { getOverviewItems } from 'uiSrc/components/database-overview/components/OverviewItems' + +import DatabaseListModules from '../database-list-modules/DatabaseListModules' +import PageBreadcrumbs from '../page-breadcrumbs' + +import styles from './styles.module.scss' + +const maxLengthModules = 6 +const middleLengthModules = 3 +const minLengthModules = 0 + +const maxLengthOverview = 5 +const minLengthOverview = 3 + +const widthResponsiveMaxSize = 1300 +const widthResponsiveMiddleSize = 1124 +const widthResponsiveLowSize = 920 + +const TIMEOUT_TO_GET_INFO = process.env.NODE_ENV !== 'development' ? 5000 : 100000 + +const ModulesInfoText = 'More information about Redis modules can be found here.\nCreate a free Redis database with modules support on Redis Cloud.\n' + +const InstanceHeader = () => { + const [lengthModules, setLengthModules] = useState(0) + const [lengthOverviewItems, setLengthOverviewItems] = useState(5) + const [isShowMoreInfoPopover, setIsShowMoreInfoPopover] = useState(false) + + const { + usedMemory, + totalKeys, + connectedClients, + cpuUsagePercentage, + networkInKbps, + networkOutKbps, + opsPerSecond, + version + } = useSelector(connectedInstanceOverviewSelector) + const { id: connectedInstanceId = '' } = useSelector(connectedInstanceSelector) + const { name = '', username = '', connectionType = '', modules = [], db = 0 } = useSelector(connectedInstanceSelector) + + const dispatch = useDispatch() + const { theme } = useContext(ThemeContext) + let interval: NodeJS.Timeout + + const overviewItems = getOverviewItems({ + theme, + items: { + usedMemory, + totalKeys, + connectedClients, + cpuUsagePercentage, + networkInKbps, + networkOutKbps, + opsPerSecond + } + }) + + useEffect(() => { + updateWindowDimensions() + globalThis.addEventListener('resize', updateWindowDimensions) + return () => { + globalThis.removeEventListener('resize', updateWindowDimensions) + } + }, []) + + const getInfo = () => { + if (document.hidden) return + + dispatch(getDatabaseConfigInfoAction( + connectedInstanceId, + () => {}, + () => clearInterval(interval) + )) + } + + useEffect(() => { + interval = setInterval(getInfo, TIMEOUT_TO_GET_INFO) + return () => clearInterval(interval) + }, [connectedInstanceId]) + + const updateWindowDimensions = () => { + if (globalThis.innerWidth > widthResponsiveMaxSize) { + setLengthOverviewItems(maxLengthOverview) + setLengthModules(maxLengthModules) + return + } + if (globalThis.innerWidth > widthResponsiveMiddleSize) { + setLengthOverviewItems(maxLengthOverview) + setLengthModules(middleLengthModules) + return + } + if (globalThis.innerWidth > widthResponsiveLowSize) { + setLengthOverviewItems(maxLengthOverview) + setLengthModules(minLengthModules) + return + } + setLengthOverviewItems(minLengthOverview) + setLengthModules(minLengthModules) + } + + const getBreadcrumbsInstanceOptions = (): BrowserPageOptions => ({ + connectedInstanceName: name, + postfix: getDbIndex(db), + connection: connectionType ? CONNECTION_TYPE_DISPLAY[connectionType] : capitalize(connectionType), + version, + user: username || 'Default' + }) + + const getContentOverview = (items: any[], truncateLength = 0) => { + const moreInfoItems = items.slice(truncateLength) + .map((overviewItem) => ( + + {overviewItem.tooltipIcon && ( + + + + )} + + { overviewItem.tooltip.content } + + + { overviewItem.tooltip.title } + + + )) + + return ( +
0 })}> + { moreInfoItems } +
+ ) + } + + const getContentModules = () => { + const modulesNames = modules?.slice(lengthModules).map(({ name = '', semanticVersion = '', version = '' }) => ( +
+ {`${truncateText(getModule(name)?.name ?? name, 50)} `} + {!!(semanticVersion || version) && ( + + v. + {' '} + {semanticVersion || version} + + )} +
+ )) + + return ( + <> +

Modules:

+

{parse(ModulesInfoText)}

+ {modulesNames ?? null} + + ) + } + + const MoreInfo = () => ( + setIsShowMoreInfoPopover(false)} + anchorClassName={styles.moreInfo} + panelClassName={cx('euiToolTip', 'popoverLikeTooltip', styles.mi_wrapper)} + button={( + setIsShowMoreInfoPopover((isOpenPopover) => !isOpenPopover)} + aria-labelledby="more info" + /> + )} + > + <> + {getContentOverview(overviewItems, lengthOverviewItems)} + {getContentModules()} + + + ) + + return ( +
+ + +
+ +
+
+ + + + +
+ +
+
+ +
+ {!!modules?.length && ( + + )} + {MoreInfo()} +
+
+
+
+
+ +
+ ) +} + +export default InstanceHeader diff --git a/redisinsight/ui/src/components/instance-header/index.ts b/redisinsight/ui/src/components/instance-header/index.ts new file mode 100644 index 0000000000..ba2d19f1b0 --- /dev/null +++ b/redisinsight/ui/src/components/instance-header/index.ts @@ -0,0 +1,3 @@ +import InstanceHeader from './InstanceHeader' + +export default InstanceHeader diff --git a/redisinsight/ui/src/components/instance-header/styles.module.scss b/redisinsight/ui/src/components/instance-header/styles.module.scss new file mode 100644 index 0000000000..93f1a506be --- /dev/null +++ b/redisinsight/ui/src/components/instance-header/styles.module.scss @@ -0,0 +1,104 @@ +.container { + padding: 6px 16px 6px; + height: 70px; + + @media only screen and (max-width: 1124px) { + .modules { + margin-left: 0; + border-left: 0; + padding-left: 12px; + padding-right: 8px; + } + + .overview { + border-right: 0; + } + } +} + +.moreInfo { + margin-right: 5px; +} + +.moreInfoOverview { + margin-bottom: 14px; +} + +.moreInfoOverviewItem { + margin-bottom: 8px; + + .moreInfoOverviewIcon { + margin-right: 8px; + } + + .moreInfoOverviewContent { + margin-right: 6px; + font-weight: 500; + font-size: 13px; + } + + .moreInfoOverviewTitle { + margin-right: 6px; + font-size: 12px; + } +} + +.mi_wrapper { + width: 220px !important; + white-space: pre-wrap; +} + +.mi_fieldName { + font-size: 13px !important; + line-height: 16px; + padding-bottom: 4px; + font-weight: 600; +} + +.mi_smallText { + font: normal normal normal 10px/14px Graphik, sans-serif !important; + color: var(--euiTooltipTextSecondColor) !important; + margin-bottom: 8px; + + a { + color: var(--euiTooltipTextColor) !important; + } +} + +.mi_version { + color: var(--euiTooltipTextSecondColor) !important; +} + +.mi_moduleName { + padding-top: 4px; + line-height: 15px; +} + +.breadcrumbsContainer { + height: 58px; + background-color: var(--euiColorEmptyShade); + border: 1px solid var(--euiColorLightShade); + padding: 0 12px; +} + +.itemContainer { + height: 58px; + background-color: var(--euiColorEmptyShade); + border: 1px solid var(--euiColorLightShade); + align-items: center; + justify-content: center; + margin-left: 6px; +} + +.modules { + padding-left: 22px; + padding-right: 8px; + + @media only screen and (max-width: 767px) { + padding-left: 10px; + } + + &.noModules { + padding-left: 12px; + } +} diff --git a/redisinsight/ui/src/components/keyboard-shortcut/KeyboardShortcut.spec.tsx b/redisinsight/ui/src/components/keyboard-shortcut/KeyboardShortcut.spec.tsx new file mode 100644 index 0000000000..8aa448c345 --- /dev/null +++ b/redisinsight/ui/src/components/keyboard-shortcut/KeyboardShortcut.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import KeyboardShortcut, { Props } from './KeyboardShortcut' + +const mockedProps = mock() + +describe('KeyboardShortcut', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/keyboard-shortcut/KeyboardShortcut.tsx b/redisinsight/ui/src/components/keyboard-shortcut/KeyboardShortcut.tsx new file mode 100644 index 0000000000..336a6c57ab --- /dev/null +++ b/redisinsight/ui/src/components/keyboard-shortcut/KeyboardShortcut.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { EuiBadge, EuiText } from '@elastic/eui' + +import styles from './styles.module.scss' + +export interface Props { + items: string[], + separator?: string +} + +const KeyboardShortcut = ({ items = [], separator = '' }: Props) => ( +
+ { + items.map((item: string, index: number) => ( +
+ { (index !== 0) &&
{separator}
} + + {item} + +
+ )) + } +
+) +export default KeyboardShortcut diff --git a/redisinsight/ui/src/components/keyboard-shortcut/styles.module.scss b/redisinsight/ui/src/components/keyboard-shortcut/styles.module.scss new file mode 100644 index 0000000000..cb840d9ad1 --- /dev/null +++ b/redisinsight/ui/src/components/keyboard-shortcut/styles.module.scss @@ -0,0 +1,17 @@ +.container { + display: flex; + align-items: center; + & > div { + display: flex; + align-items: center; + } +} + +.separator { + margin: 0 4px; +} + +.badge { + background-color: var(--euiTooltipBackgroundColor) !important; + border: 1px solid var(--euiToastSuccessBtnColor) !important;; +} diff --git a/redisinsight/ui/src/components/main-router/MainRouter.tsx b/redisinsight/ui/src/components/main-router/MainRouter.tsx new file mode 100644 index 0000000000..6a0c733355 --- /dev/null +++ b/redisinsight/ui/src/components/main-router/MainRouter.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { Redirect, Switch } from 'react-router-dom' +import ROUTES from 'uiSrc/constants/routes' +import extractRouter from 'uiSrc/hoc/extractRouter.hoc' +import { registerRouter } from 'uiSrc/services/routing' +import RouteWithSubRoutes from 'uiSrc/utils/routerWithSubRoutes' + +const MainRouter = () => ( + + {ROUTES.map((route, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + +) + +const MainMount: any = extractRouter(registerRouter)(MainRouter) + +export default MainMount diff --git a/redisinsight/ui/src/components/main-router/interfaces.ts b/redisinsight/ui/src/components/main-router/interfaces.ts new file mode 100644 index 0000000000..7f3e32b497 --- /dev/null +++ b/redisinsight/ui/src/components/main-router/interfaces.ts @@ -0,0 +1,50 @@ +import { KeyTypes, UnsupportedKeyTypes } from 'uiSrc/constants' +import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' +import { Maybe, Nullable } from 'uiSrc/utils' + +export interface RouteParams { + instanceId: string; +} + +export interface Key { + name: string; + type: KeyTypes; + ttl: number; + size: number; +} + +export interface KeysStore { + loading: boolean; + error: string; + search: string; + filter: Nullable; + isFiltered: boolean; + isSearched: boolean; + data: { + total: number; + scanned: number; + nextCursor: string; + keys: Key[]; + shardsMeta: Record; + previousResultCount: number; + lastRefreshTime: Nullable; + }; + selectedKey: { + loading: boolean; + refreshing: boolean; + lastRefreshTime: Nullable; + error: string; + data: Nullable; + length: Maybe; + }; + addKey: { + loading: boolean; + error: string; + }; +} diff --git a/redisinsight/ui/src/components/main/MainComponent.tsx b/redisinsight/ui/src/components/main/MainComponent.tsx new file mode 100644 index 0000000000..eeef836fb4 --- /dev/null +++ b/redisinsight/ui/src/components/main/MainComponent.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +import MainRouter from '../main-router/MainRouter' + +const MainComponent = () => +// here will be CLI for all pages + + +export default MainComponent diff --git a/redisinsight/ui/src/components/message-bar/MessageBar.spec.tsx b/redisinsight/ui/src/components/message-bar/MessageBar.spec.tsx new file mode 100644 index 0000000000..258f66cac7 --- /dev/null +++ b/redisinsight/ui/src/components/message-bar/MessageBar.spec.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { fireEvent, screen, render } from 'uiSrc/utils/test-utils' +import MessageBar, { Props } from './MessageBar' + +const mockedProps = mock() +const CLOSE_BUTTON = 'close-button' + +describe('MessageBar', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render children', () => { + render( + +

lorem ipsum

+
+ ) + expect(screen.getByTestId('text')).toBeTruthy() + }) + + it('should close after click cancel', () => { + render( + + ) + + expect(screen.getByTestId(CLOSE_BUTTON)).toBeInTheDocument() + + fireEvent( + screen.getByTestId(CLOSE_BUTTON), + new MouseEvent('click', { bubbles: true }) + ) + expect(screen.queryByTestId(CLOSE_BUTTON)).toBeNull() + }) +}) diff --git a/redisinsight/ui/src/components/message-bar/MessageBar.tsx b/redisinsight/ui/src/components/message-bar/MessageBar.tsx new file mode 100644 index 0000000000..ff6ba56454 --- /dev/null +++ b/redisinsight/ui/src/components/message-bar/MessageBar.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from 'react' +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui' + +import styles from './styles.module.scss' + +export interface Props { + children?: React.ReactElement; + opened: boolean +} + +const MessageBar = ({ + children, + opened, +}: Props) => { + const [isOpen, setIsOpen] = useState(false) + useEffect(() => { + setIsOpen(opened) + }, [opened]) + + return ( + isOpen ? ( +
+
+ + + {children} + + + setIsOpen(false)} + data-testid="close-button" + /> + + +
+
+ ) : null + ) +} + +export default MessageBar diff --git a/redisinsight/ui/src/components/message-bar/styles.module.scss b/redisinsight/ui/src/components/message-bar/styles.module.scss new file mode 100644 index 0000000000..15b0330bde --- /dev/null +++ b/redisinsight/ui/src/components/message-bar/styles.module.scss @@ -0,0 +1,67 @@ +:global { + .euiPopoverTitle { + text-transform: none !important; + } + + .euiButton { + min-width: 93px !important; + + &--small { + min-width: 67px !important; + } + + &:focus { + text-decoration: none !important; + } + } +} + +.containerWrapper { + position: absolute; + + min-width: 332px; + min-height: 48px; + left: 0; + bottom: 12px; + width: 100%; + z-index: 10; + + display: flex; + align-items: center; + justify-content: center; +} + +.container { + background-color: var(--euiTooltipBackgroundColor); + border-radius: 20px; + padding: 0 25px 0 35px; + flex-grow: 0 !important; + max-width: 80%; + min-height: 48px; + box-shadow: 0 3px 15px var(--controlsBoxShadowColor); +} + +.text { + font-size: 13px; + text-align: center; + color: var(--euiColorPrimaryText); + > div { + font-size: 13px; + } +} +.actions { + span, + svg { + font-size: 14px !important; + } + + svg { + width: 14px; + height: 14px; + } +} + +.cross svg { + width: 20px; + height: 20px; +} diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx new file mode 100644 index 0000000000..26d7a95949 --- /dev/null +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' + +import NavigationMenu from './NavigationMenu' + +describe('NavigationMenu', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx new file mode 100644 index 0000000000..0ce0091ed3 --- /dev/null +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx @@ -0,0 +1,270 @@ +import React, { useEffect, useState } from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import cx from 'classnames' +import { last } from 'lodash' +import { useDispatch, useSelector } from 'react-redux' +import { + EuiPageSideBar, + EuiButtonIcon, + EuiToolTip, + EuiLink, + EuiIcon, + EuiPopover, + EuiTitle, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText +} from '@elastic/eui' + +import { PageNames, Pages } from 'uiSrc/constants' +import { getRouterLinkProps } from 'uiSrc/services' +import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { setReleaseNotesViewed, appElectronInfoSelector } from 'uiSrc/slices/app/info' +import LogoSVG from 'uiSrc/assets/img/logo.svg' +import SettingsSVG from 'uiSrc/assets/img/sidebar/settings.svg' +import SettingsActiveSVG from 'uiSrc/assets/img/sidebar/settings_active.svg' +import BrowserSVG from 'uiSrc/assets/img/sidebar/browser.svg' +import BrowserActiveSVG from 'uiSrc/assets/img/sidebar/browser_active.svg' +import WorkbenchSVG from 'uiSrc/assets/img/sidebar/workbench.svg' +import WorkbenchActiveSVG from 'uiSrc/assets/img/sidebar/workbench_active.svg' +import Divider from 'uiSrc/components/divider/Divider' + +import styles from './styles.module.scss' + +const workbenchPath = `/${PageNames.workbench}` +const browserPath = `/${PageNames.browser}` + +interface INavigations { + isActivePage: boolean; + tooltipText: string; + ariaLabel: string; + dataTestId: string; + connectedInstanceId?: string; + onClick: () => void; + getClassName: () => string; + getIconType: () => string; +} + +const NavigationMenu = () => { + const history = useHistory() + const location = useLocation() + const dispatch = useDispatch() + + const [activePage, setActivePage] = useState(Pages.home) + const [isHelpMenuActive, setIsHelpMenuActive] = useState(false) + + const { id: connectedInstanceId = '' } = useSelector(connectedInstanceSelector) + const { isReleaseNotesViewed } = useSelector(appElectronInfoSelector) + + useEffect(() => { + setActivePage(`/${last(location.pathname.split('/'))}`) + }, [location]) + + const handleGoSettingsPage = () => { + history.push(Pages.settings) + } + const handleGoWorkbenchPage = () => { + history.push(Pages.workbench(connectedInstanceId)) + } + const handleGoBrowserPage = () => { + history.push(Pages.browser(connectedInstanceId)) + } + + const privateRoutes: INavigations[] = [ + { + tooltipText: 'Browser', + isActivePage: activePage === browserPath, + ariaLabel: 'Browser page button', + onClick: handleGoBrowserPage, + dataTestId: 'browser-page-btn', + connectedInstanceId, + getClassName() { + return cx(styles.navigationButton, { [styles.active]: this.isActivePage }) + }, + getIconType() { + return this.isActivePage ? BrowserSVG : BrowserActiveSVG + }, + }, + { + tooltipText: 'Workbench', + ariaLabel: 'Workbench page button', + onClick: handleGoWorkbenchPage, + dataTestId: 'workbench-page-btn', + connectedInstanceId, + isActivePage: activePage === workbenchPath, + getClassName() { + return cx(styles.navigationButton, { [styles.active]: this.isActivePage }) + }, + getIconType() { + return this.isActivePage ? WorkbenchSVG : WorkbenchActiveSVG + }, + }, + ] + + const publicRoutes: INavigations[] = [ + { + tooltipText: 'Settings', + ariaLabel: 'Settings page button', + onClick: handleGoSettingsPage, + dataTestId: 'settings-page-btn', + isActivePage: activePage === Pages.settings, + getClassName() { + return cx(styles.navigationButton, { [styles.active]: this.isActivePage }) + }, + getIconType() { + return this.isActivePage ? SettingsActiveSVG : SettingsSVG + }, + }, + ] + + const onClickReleaseNotes = async () => { + if (isReleaseNotesViewed === false) { + dispatch(setReleaseNotesViewed(true)) + } + } + + const HelpMenuButton = () => ( + setIsHelpMenuActive((value) => !value)} + data-testid="help-menu-button" + /> + ) + + const HelpMenu = () => ( + setIsHelpMenuActive(false)} + button={( + <> + {!isHelpMenuActive && ( + + {HelpMenuButton()} + + )} + + {isHelpMenuActive && HelpMenuButton()} + + )} + > +
+ + Help Center + + + + + + + + + Submit a Bug or Idea + + + + + + + + + Keyboard Shortcuts + + + + + +
+ +
+ + Release Notes +
+
+ +
+
+
+ ) + + return ( + +
+ + + + + + + + + + + {connectedInstanceId && ( + privateRoutes.map((nav) => ( + + + + )) + )} +
+
+ + + {HelpMenu()} + {publicRoutes.map((nav) => ( + + + + ))} +
+
+ ) +} + +export default NavigationMenu diff --git a/redisinsight/ui/src/components/navigation-menu/styles.module.scss b/redisinsight/ui/src/components/navigation-menu/styles.module.scss new file mode 100644 index 0000000000..6667b83657 --- /dev/null +++ b/redisinsight/ui/src/components/navigation-menu/styles.module.scss @@ -0,0 +1,154 @@ +$sideBarWidth: 60px; + +.container, .bottomContainer { + min-width: $sideBarWidth; + position: relative; + display: flex; + + @media only screen and (min-width: 768px) { + flex-direction: column; + } + + .navigationButton { + min-width: 60px; + min-height: 60px; + height: 60px; + width: 60px; + + border-radius: 0; + color: #BDC3D7 !important; + + &:hover { + background-color: #34406f !important; + &.navigationButtonNotified { + &:before { + border-color: #34406f !important; + } + } + } + + &.active { + background-color: var(--euiColorSuccessText) !important; + } + + &.navigationButtonNotified { + &:before { + content: ''; + position: absolute; + top: 16px; + right: 16px; + width: 12px; + height: 12px; + border: 2px solid var(--navBackgroundColor); + background-color: var(--euiColorPrimary); + border-radius: 100%; + z-index: 1; + } + } + + img { + width: 20px; + height: 20px; + } + } +} + + +.navigation { + background: var(--navBackgroundColor) !important; + padding: 32px 0; + display: flex !important; + flex-direction: column; + justify-content: space-between; + margin-bottom: 0 !important; + + @media screen and (max-width: 767px) { + flex-direction: row !important; + } +} + +.dockController { + position: absolute; + bottom: 0; + width: 100%; + background-color: var(--navBackgroundColor); +} + +.iconLogo { + display: inline-flex; + height: 60px; + width: 60px; + + align-items: center; + justify-content: center; + + @media only screen and (min-width: 768px) { + height: 60px; + width: 60px; + } + + :global(.euiIcon) { + width: 34px; + height: 34px; + } +} + +.popoverWrapper { + min-width: 354px !important; +} + +.popover { + padding: 5px 15px 5px; +} + +.helpMenuItem { + align-items: center; + + :global(.euiButtonIcon), :global(.euiIcon) { + color: var(--euiTooltipTextColor) !important; + } + + .helpMenuItemLink { + &:global(.euiLink) { + text-decoration: none !important; + display: flex; + flex-direction: column; + align-items: center; + transition: transform 0.3s ease; + + &:hover { + transform: translateY(-1px); + } + + &:focus { + animation: none !important; + } + } + } +} + +.helpMenuItemDisabled { + :global(.euiIcon), div { + color: var(--buttonSecondaryDisabledTextColor) !important; + } +} + +.helpMenuItemNotified { + position: relative; + &:before { + content: ''; + position: absolute; + right: -2px; + top: -3px; + display: block; + width: 8px; + height: 8px; + background-color: var(--euiColorPrimary); + border-radius: 100%; + } +} + +.helpMenuText { + font-size: 13px !important; + line-height: 1.35 !important; +} diff --git a/redisinsight/ui/src/components/notifications/Notifications.spec.tsx b/redisinsight/ui/src/components/notifications/Notifications.spec.tsx new file mode 100644 index 0000000000..c82dd7a20c --- /dev/null +++ b/redisinsight/ui/src/components/notifications/Notifications.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' +import Notifications from './Notifications' + +jest.mock('uiSrc/slices/app/notifications', () => ({ + messagesSelector: jest.fn().mockReturnValue([{ + id: '1', + title: 'Header text', + message: 'Body text' + }]), + errorsSelector: jest.fn().mockReturnValue([ + { + id: '2', + message: 'Body text' + } + ]), + removeMessage: jest.fn +})) + +describe('Notifications', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/notifications/Notifications.tsx b/redisinsight/ui/src/components/notifications/Notifications.tsx new file mode 100644 index 0000000000..9cc3070117 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/Notifications.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + EuiGlobalToastList, + EuiButton, + EuiSpacer, + EuiFlexItem, + EuiFlexGroup, + EuiTextColor, +} from '@elastic/eui' +import { Toast } from '@elastic/eui/src/components/toast/global_toast_list' +import { + errorsSelector, + messagesSelector, + removeMessage, +} from 'uiSrc/slices/app/notifications' +import { setReleaseNotesViewed } from 'uiSrc/slices/app/info' +import { IError, IMessage } from 'uiSrc/slices/interfaces' +import { ApiEncryptionErrors } from 'uiSrc/constants/apiErrors' + +import errorMessages from './error-messages' + +import styles from './styles.module.scss' + +const DEFAULT_TEXT = 'Something went wrong.' + +const Notifications = () => { + const messagesData = useSelector(messagesSelector) + const errorsData = useSelector(errorsSelector) + const dispatch = useDispatch() + + const removeToast = ({ id }: Toast) => { + dispatch(removeMessage(id)) + } + + const onSubmitNotification = ({ id }: Toast, group?: string) => { + if (group === 'upgrade') { + dispatch(setReleaseNotesViewed(true)) + } + dispatch(removeMessage(id)) + } + + const getSuccessText = ( + text: string | JSX.Element | JSX.Element[], + toast: Toast, + group?: string + ) => ( + <> + {text} + + + + onSubmitNotification(toast, group)} + className={styles.toastSuccessBtn} + > + Ok + + + + + ) + + const getSuccessToasts = (data: IMessage[]) => + data.map(({ id = '', title = '', message = '', group }) => { + const toast: Toast = { + id, + iconType: 'iInCircle', + title: ( + + {title} + + ), + color: 'success', + } + toast.text = getSuccessText(message, toast, group) + toast.onClose = () => removeToast(toast) + + return toast + }) + + const getErrorsToasts = (errors: IError[]) => + errors.map(({ id = '', message = DEFAULT_TEXT, instanceId = '', name }) => { + if (ApiEncryptionErrors.includes(name)) { + return errorMessages.ENCRYPTION(id, () => removeToast({ id }), instanceId) + } + return errorMessages.DEFAULT(id, message, () => removeToast({ id })) + }) + + return ( + + ) +} + +export default Notifications diff --git a/redisinsight/ui/src/components/notifications/components/DefaultErrorContent.tsx b/redisinsight/ui/src/components/notifications/components/DefaultErrorContent.tsx new file mode 100644 index 0000000000..31b82dba2b --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/DefaultErrorContent.tsx @@ -0,0 +1,31 @@ +import { EuiButton, EuiSpacer, EuiTextColor } from '@elastic/eui' +import React from 'react' + +export interface Props { + text: string | JSX.Element | JSX.Element[]; + onClose?: () => void; +} +// TODO: use i18n file for texts +const DefaultErrorContent = ( + { + text, + onClose = () => {}, + }: Props +) => ( + <> + {text} + + + Ok + + +) + +export default DefaultErrorContent diff --git a/redisinsight/ui/src/components/notifications/components/EncryptionErrorContent.tsx b/redisinsight/ui/src/components/notifications/components/EncryptionErrorContent.tsx new file mode 100644 index 0000000000..a847676dae --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/EncryptionErrorContent.tsx @@ -0,0 +1,80 @@ +import React from 'react' +import { EuiButton, EuiFlexGroup, EuiSpacer, EuiTextColor, EuiFlexItem } from '@elastic/eui' +import { matchPath, useHistory, useLocation } from 'react-router-dom' +import { useDispatch } from 'react-redux' +import { Pages } from 'uiSrc/constants' +import { updateUserConfigSettingsAction } from 'uiSrc/slices/user/user-settings' + +export interface Props { + onClose?: () => void; + instanceId?: string; +} + +// TODO: use i18n file for texts +const EncryptionErrorContent = (props: Props) => { + const { onClose } = props + const { pathname } = useLocation() + const history = useHistory() + const dispatch = useDispatch() + + // useParams() hook can't be used because the Notifications component is outside of the MainRouter + const getInstanceIdFromUrl = (): string => { + const path = '/:instanceId/(browser|workbench)/' + const match: any = matchPath(pathname, { path }) + return match?.params?.instanceId + } + + const disableEncryption = () => { + const instanceId = props.instanceId || getInstanceIdFromUrl() + dispatch(updateUserConfigSettingsAction({ agreements: { encryption: false } })) + if (instanceId) { + history.push(Pages.homeEditInstance(instanceId)) + } + if (onClose) { + onClose() + } + } + return ( + <> + + Check the system keychain or disable encryption to proceed. + + + + Disabling encryption will result in storing sensitive information locally in plain text. + Re-enter database connection information to work with databases. + + + + +
+ + Disable Encryption + +
+
+ +
+ + Cancel + +
+
+
+ + ) +} +export default EncryptionErrorContent diff --git a/redisinsight/ui/src/components/notifications/components/index.ts b/redisinsight/ui/src/components/notifications/components/index.ts new file mode 100644 index 0000000000..7c1a358c2b --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/index.ts @@ -0,0 +1,7 @@ +import DefaultErrorContent from './DefaultErrorContent' +import EncryptionErrorContent from './EncryptionErrorContent' + +export { + EncryptionErrorContent, + DefaultErrorContent, +} diff --git a/redisinsight/ui/src/components/notifications/error-messages.tsx b/redisinsight/ui/src/components/notifications/error-messages.tsx new file mode 100644 index 0000000000..e5c9a9d0cf --- /dev/null +++ b/redisinsight/ui/src/components/notifications/error-messages.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { EuiTextColor } from '@elastic/eui' +import { Toast } from '@elastic/eui/src/components/toast/global_toast_list' +import { EncryptionErrorContent, DefaultErrorContent } from './components' + +// TODO: use i18n file for texts +export default { + DEFAULT: (id: string, text: any, onClose = () => {}, title: string = 'Error'): Toast => ({ + id, + 'data-test-subj': 'toast-error', + color: 'danger', + iconType: 'alert', + onClose, + title: ( + + {title} + + ), + text: , + }), + ENCRYPTION: (id: string, onClose = () => {}, instanceId = ''): Toast => ({ + id, + 'data-test-subj': 'toast-error-encryption', + color: 'danger', + iconType: 'iInCircle', + onClose, + toastLifeTimeMs: 1000 * 60 * 60 * 12, // 12hr, + title: ( + + Unable to decrypt + + ), + text: , + }), +} diff --git a/redisinsight/ui/src/components/notifications/styles.module.scss b/redisinsight/ui/src/components/notifications/styles.module.scss new file mode 100644 index 0000000000..c4432a06cd --- /dev/null +++ b/redisinsight/ui/src/components/notifications/styles.module.scss @@ -0,0 +1,18 @@ +.toastSuccessBtn { + background-color: var(--euiToastSuccessBtnColor) !important; + border: none !important; +} + +.list { + font: normal normal normal 12px/17px Graphik, sans-serif; + font-weight: 400; + padding-bottom: 10px; + + &:first-of-type { + padding-top: 10px; + } +} + +:global(.euiToast) { + box-shadow: none !important; +} diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx new file mode 100644 index 0000000000..c8c4fba87e --- /dev/null +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -0,0 +1,115 @@ +import React from 'react' +import { formatNameShort, Maybe } from 'uiSrc/utils' +import styles from './styles.module.scss' + +// TODO: use i18n file for texts +export default { + ADDED_NEW_INSTANCE: (instanceName: string) => ({ + title: 'Database has been added', + message: ( + <> + {formatNameShort(instanceName)} + {' '} + has been added to RedisInsight. + + ), + }), + DELETE_INSTANCE: (instanceName: string) => ({ + title: 'Database has been deleted', + message: ( + <> + {formatNameShort(instanceName)} + {' '} + has been deleted from RedisInsight. + + ), + }), + DELETE_INSTANCES: (instanceNames: Maybe[]) => { + const limitShowRemovedInstances = 10 + return { + title: 'Databases have been deleted', + message: ( + <> + + {instanceNames.length} + {' '} + databases have been deleted from RedisInsight: + +
    + {instanceNames.slice(0, limitShowRemovedInstances).map((el, i) => ( +
  • + {formatNameShort(el)} +
  • + ))} + {instanceNames.length >= limitShowRemovedInstances &&
  • ...
  • } +
+ + ), + } + }, + ADDED_NEW_KEY: (keyName: string) => ({ + title: 'Key has been added', + message: ( + <> + {formatNameShort(keyName)} + {' '} + has been added. Please refresh the list of Keys to see + updates. + + ), + }), + DELETED_KEY: (keyName: string) => ({ + title: 'Key has been deleted', + message: ( + <> + {formatNameShort(keyName)} + {' '} + has been deleted. + + ), + }), + REMOVED_KEY_VALUE: (keyName: string, keyValue: string, valueType: string) => ({ + title: ( + <> + {valueType} + {' '} + has been removed + + ), + message: ( + <> + {formatNameShort(keyValue)} + {' '} + has been removed from   + {formatNameShort(keyName)} + + ), + }), + REMOVED_LIST_ELEMENTS: (keyName: string, numberOfElements: number, listOfElements: string[]) => { + const limitShowRemovedElements = 10 + return { + title: 'Elements have been removed', + message: ( + <> + + {`${numberOfElements} Element(s) removed from ${formatNameShort(keyName)}:`} + +
    + {listOfElements.slice(0, limitShowRemovedElements).map((el, i) => ( +
  • + {formatNameShort(el)} +
  • + ))} + {listOfElements.length >= limitShowRemovedElements &&
  • ...
  • } +
+ + ), + } + }, + INSTALLED_NEW_UPDATE: (updateDownloadedVersion: string) => ({ + title: 'Application updated', + message: `Your application has been updated to ${updateDownloadedVersion}. Find more + information in Release Notes.`, + group: 'upgrade' + }), +} diff --git a/redisinsight/ui/src/components/page-breadcrumbs/PageBreadcrumbs.spec.tsx b/redisinsight/ui/src/components/page-breadcrumbs/PageBreadcrumbs.spec.tsx new file mode 100644 index 0000000000..416e5c820c --- /dev/null +++ b/redisinsight/ui/src/components/page-breadcrumbs/PageBreadcrumbs.spec.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { render, fireEvent } from 'uiSrc/utils/test-utils' +import PageBreadcrumbs, { Breadcrumb } from './PageBreadcrumbs' + +const onClick = jest.fn() +const breadcrumbs: Breadcrumb[] = [ + { + text: 'first', + href: '/', + 'data-test-subject': 'first-link', + onClick + }, + { + text: 'second', + href: '/', + 'data-test-subject': 'second-link', + }, + { + text: 'third' + } +] + +describe('PageBreadcrumbs', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render properly', () => { + const { container } = render() + expect(container.querySelector('[data-test-subject="first-link"]')).toBeInTheDocument() + }) + + it('should call onClick', () => { + const { container } = render() + fireEvent.click(container.querySelector('[data-test-subject="first-link"]') as Element) + expect(onClick).toBeCalled() + }) +}) diff --git a/redisinsight/ui/src/components/page-breadcrumbs/PageBreadcrumbs.tsx b/redisinsight/ui/src/components/page-breadcrumbs/PageBreadcrumbs.tsx new file mode 100644 index 0000000000..cfb29f0d58 --- /dev/null +++ b/redisinsight/ui/src/components/page-breadcrumbs/PageBreadcrumbs.tsx @@ -0,0 +1,83 @@ +import React, { ReactNode } from 'react' +import { useHistory } from 'react-router-dom' +import { EuiBreadcrumbs, EuiSpacer, EuiToolTip } from '@elastic/eui' +import { EuiBreadcrumb } from '@elastic/eui/src/components/breadcrumbs/breadcrumbs' + +import styles from './styles.module.scss' + +interface TooltipOption { + label: string, + value: any +} + +export interface Breadcrumb extends EuiBreadcrumb { + text: string | ReactNode; + postfix?: string | ReactNode; + tooltipOptions?: TooltipOption[]; + href?: string; + 'data-test-subject'?: string; +} + +interface Props { + breadcrumbs: Breadcrumb[]; +} + +const PageBreadcrumbs = (props: Props) => { + const { breadcrumbs } = props + const history = useHistory() + + const modifiedBreadcrumbs: EuiBreadcrumb[] = breadcrumbs.map((breadcrumb) => { + const { tooltipOptions, ...modifiedBreadcrumb }: Breadcrumb = { ...breadcrumb } + const { href, onClick, text = '', postfix = '' } = breadcrumb + + if (href && !onClick) { + modifiedBreadcrumb.onClick = (e) => { + e.preventDefault() + history.push(href) + } + } + + modifiedBreadcrumb.text = ( + + {tooltipOptions?.length ? ( + tooltipOptions.map(({ label, value }) => ( +
+ {label} + : + {value} +
+ )) + ) : text} + + )} + > + <> + {text} + {!!postfix && {postfix}} + +
+ ) + + return modifiedBreadcrumb + }) + + return ( +
+ + +
+ ) +} + +export default PageBreadcrumbs diff --git a/redisinsight/ui/src/components/page-breadcrumbs/index.ts b/redisinsight/ui/src/components/page-breadcrumbs/index.ts new file mode 100644 index 0000000000..0a33a85bf8 --- /dev/null +++ b/redisinsight/ui/src/components/page-breadcrumbs/index.ts @@ -0,0 +1,3 @@ +import PageBreadcrumbs from './PageBreadcrumbs' + +export default PageBreadcrumbs diff --git a/redisinsight/ui/src/components/page-breadcrumbs/styles.module.scss b/redisinsight/ui/src/components/page-breadcrumbs/styles.module.scss new file mode 100644 index 0000000000..b5a0a91cbd --- /dev/null +++ b/redisinsight/ui/src/components/page-breadcrumbs/styles.module.scss @@ -0,0 +1,69 @@ +.breadcrumbsWrapper { + color: var(--euiTextSubduedColor); + display: flex; + height: 58px; + + :global(.euiBreadcrumb) { + margin-bottom: 0; + font-size: 13px; + font-weight: 500; + color: var(--euiTextSubduedColor) !important; + + > span { + display: inline-flex; + align-items: center; + max-width: 100%; + vertical-align: super; + } + + &:focus { + background: none !important; + } + + &:hover { + color: var(--euiBreadcrumbActive) !important; + } + } + + :global(.euiBreadcrumb.euiLink.euiLink--subdued:focus) { + animation: none !important; + } + + :global(.euiBreadcrumb--last) { + color: var(--euiBreadcrumbActive) !important; + } + + :global(.euiBreadcrumbSeparator) { + margin-right: 12px; + width: 7px; + height: 7px; + margin-bottom: 4px; + transform: rotate(45deg); + border-right: 1px solid currentColor; + border-top: 1px solid currentColor; + background: none; + } +} + +.breadcrumbText { + display: inline-block !important; + overflow: hidden; + text-overflow: ellipsis; +} + +.breadcrumbPostfix { + padding-left: 3px; +} + +.tooltipItem { + margin-bottom: 4px; +} + +.tooltipItemValue { + margin-left: 4px; + font-weight: 300; +} + +.tooltip { + max-width: 372px !important; +} diff --git a/redisinsight/ui/src/components/page-header/PageHeader.module.scss b/redisinsight/ui/src/components/page-header/PageHeader.module.scss new file mode 100644 index 0000000000..626067c7ed --- /dev/null +++ b/redisinsight/ui/src/components/page-header/PageHeader.module.scss @@ -0,0 +1,37 @@ +@import '@elastic/eui/src/global_styling/index'; + +.pageHeader { + background-color: var(--euiColorEmptyShade); + border-bottom: 1px solid var(--euiColorLightShade); +} + +.pageHeaderTop { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 8px 16px; + @include euiBreakpoint('s', 'xs') { + flex-direction: column-reverse; + > div { + width: 100%; + } + .pageHeaderLogo { + display: flex; + justify-content: center; + } + } +} + +.logo { + transition: transform 0.1s linear; + + &:hover { + transform: translateY(-1px) !important; + } + + img { + height: 28px !important; + width: 150px !important; + } +} diff --git a/redisinsight/ui/src/components/page-header/PageHeader.tsx b/redisinsight/ui/src/components/page-header/PageHeader.tsx new file mode 100644 index 0000000000..55bd303581 --- /dev/null +++ b/redisinsight/ui/src/components/page-header/PageHeader.tsx @@ -0,0 +1,72 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React, { useContext } from 'react' +import { EuiButtonEmpty, EuiTitle } from '@elastic/eui' +import { useDispatch } from 'react-redux' +import { useHistory } from 'react-router-dom' + +import { Theme, Pages } from 'uiSrc/constants' +import { resetDataRedisCloud } from 'uiSrc/slices/cloud' +import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { resetDataRedisCluster } from 'uiSrc/slices/cluster' +import { resetDataSentinel } from 'uiSrc/slices/sentinel' + +import darkLogo from 'uiSrc/assets/img/dark_logo.svg' +import lightLogo from 'uiSrc/assets/img/light_logo.svg' + +import styles from './PageHeader.module.scss' + +interface Props { + title: string; + subtitle?: string; + children?: React.ReactNode; +} + +const PageHeader = ({ title, subtitle, children }: Props) => { + const history = useHistory() + const dispatch = useDispatch() + const { theme } = useContext(ThemeContext) + + const resetConnections = () => { + dispatch(resetDataRedisCluster()) + dispatch(resetDataRedisCloud()) + dispatch(resetDataSentinel()) + } + + const goHome = () => { + resetConnections() + history.push(Pages.home) + } + + return ( +
+
+
+ +

+ {title} +

+
+ {subtitle ? {subtitle} : ''} +
+
+ +
+
+ {children ?
{children}
: ''} +
+ ) +} + +PageHeader.defaultProps = { + subtitle: null, + children: null, +} + +export default PageHeader diff --git a/redisinsight/ui/src/components/query-card/QueryCard.spec.tsx b/redisinsight/ui/src/components/query-card/QueryCard.spec.tsx new file mode 100644 index 0000000000..c2b4fc04f8 --- /dev/null +++ b/redisinsight/ui/src/components/query-card/QueryCard.spec.tsx @@ -0,0 +1,66 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { cleanup, fireEvent, mockedStore, render } from 'uiSrc/utils/test-utils' +import QueryCard, { Props } from './QueryCard' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/services', () => ({ + ...jest.requireActual('uiSrc/services'), + sessionStorageService: { + set: jest.fn(), + get: jest.fn(), + }, +})) + +jest.mock('uiSrc/slices/app/plugins', () => ({ + ...jest.requireActual('uiSrc/slices/app/plugins'), + appPluginsSelector: jest.fn().mockReturnValue({ + visualizations: [] + }), +})) + +describe('QueryCard', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('Cli result should not in the document before Expand', () => { + const cliResultTestId = 'query-cli-result' + + const { queryByTestId } = render() + + const cliResultEl = queryByTestId(cliResultTestId) + expect(cliResultEl).not.toBeInTheDocument() + }) + + it.only('Cli result should in the document after Expand', () => { + const cardHeaderTestId = 'query-card-open' + const cliResultTestId = 'query-cli-result' + + const { queryByTestId } = render() + + const cardHeaderTestEl = queryByTestId(cardHeaderTestId) + let cliResultEl = queryByTestId(cliResultTestId) + + expect(cliResultEl).not.toBeInTheDocument() + + fireEvent.click(cardHeaderTestEl) + + cliResultEl = queryByTestId(cliResultTestId) + + expect(cliResultEl).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/components/query-card/QueryCard.tsx b/redisinsight/ui/src/components/query-card/QueryCard.tsx new file mode 100644 index 0000000000..45c4f9ce58 --- /dev/null +++ b/redisinsight/ui/src/components/query-card/QueryCard.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' +import cx from 'classnames' +import { EuiLoadingContent, keys } from '@elastic/eui' +import { WBQueryType } from 'uiSrc/pages/workbench/constants' +import { getWBQueryType, Nullable, getVisualizationsByCommand, Maybe } from 'uiSrc/utils' + +import { appPluginsSelector } from 'uiSrc/slices/app/plugins' +import { IPluginVisualization } from 'uiSrc/slices/interfaces' +import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' + +import QueryCardHeader from './QueryCardHeader' +import QueryCardCliResult from './QueryCardCliResult' +import QueryCardCliPlugin from './QueryCardCliPlugin' +import QueryCardCommonResult from './QueryCardCommonResult' + +import styles from './styles.module.scss' + +export interface Props { + id: number; + query: string; + data: any; + status: Maybe; + fromStore: boolean; + matched?: number; + time?: number; + loading?: boolean; + onQueryRun: (queryType: WBQueryType) => void; + onQueryDelete: () => void; + onQueryReRun: () => void; +} + +const getDefaultPlugin = (views: IPluginVisualization[], query: string) => + getVisualizationsByCommand(query, views).find((view) => view.default)?.uniqId || '' + +const QueryCard = (props: Props) => { + const { visualizations = [] } = useSelector(appPluginsSelector) + const { + id, + query, + data, + status, + fromStore, + time, + onQueryRun, + onQueryDelete, + onQueryReRun, + loading + } = props + + const [isOpen, setIsOpen] = useState(!fromStore) + const [isFullScreen, setIsFullScreen] = useState(false) + const [result, setResult] = useState>(data) + const [queryType, setQueryType] = useState(getWBQueryType(query, visualizations)) + const [viewTypeSelected, setViewTypeSelected] = useState(queryType) + const [selectedViewValue, setSelectedViewValue] = useState( + getDefaultPlugin(visualizations, query) || queryType + ) + const [summaryText, setSummaryText] = useState('') + + useEffect(() => { + window.addEventListener('keydown', handleEscFullScreen) + return () => { + window.removeEventListener('keydown', handleEscFullScreen) + } + }, [isFullScreen]) + + const handleEscFullScreen = (event: KeyboardEvent) => { + if (event.key === keys.ESCAPE && isFullScreen) { + toggleFullScreen() + } + } + + const toggleFullScreen = () => { + setIsFullScreen((value) => !value) + } + + useEffect(() => { + setQueryType(getWBQueryType(query, visualizations)) + }, [query]) + + useEffect(() => { + if (visualizations.length) { + const type = getWBQueryType(query, visualizations) + setQueryType(type) + setViewTypeSelected(type) + setSelectedViewValue(getDefaultPlugin(visualizations, query) || queryType) + } + }, [visualizations]) + + useEffect(() => { + if (data !== undefined) { + setResult(data) + } + }, [data, time]) + + const toggleOpen = () => { + if (isFullScreen) return + setIsOpen(!isOpen) + + if (!isOpen && !data) { + onQueryRun(queryType) + } + } + + const changeViewTypeSelected = (type: WBQueryType, value: string) => { + onQueryRun(type) + setResult(undefined) + setViewTypeSelected(type) + setSelectedViewValue(value) + } + + return ( +
+
+ + {isOpen && ( + <> + {React.isValidElement(result) + ? + : ( + <> + {viewTypeSelected === WBQueryType.Plugin && ( + <> + {!loading && result !== undefined ? ( + + ) : ( +
+ +
+ )} + + )} + {viewTypeSelected === WBQueryType.Text && ( + + )} + + )} + + )} +
+
+ ) +} + +export default React.memo(QueryCard) diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx new file mode 100644 index 0000000000..9584383946 --- /dev/null +++ b/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx @@ -0,0 +1,182 @@ +import React, { useContext, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' +import { EuiFlexItem, EuiIcon, EuiLoadingContent, EuiTextColor } from '@elastic/eui' +import { pluginApi } from 'uiSrc/services/PluginAPI' +import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { getBaseApiUrl, Nullable, Maybe } from 'uiSrc/utils' +import { Theme } from 'uiSrc/constants' +import { IPluginVisualization } from 'uiSrc/slices/interfaces' +import { PluginEvents } from 'uiSrc/plugins/pluginEvents' +import { prepareIframeHtml } from 'uiSrc/plugins/pluginImport' +import { appPluginsSelector, sendPluginCommandAction } from 'uiSrc/slices/app/plugins' +import { connectedInstanceSelector } from 'uiSrc/slices/instances' +import { CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' + +import styles from './styles.module.scss' + +export interface Props { + result: any + query: any + id: string + status: Maybe + setSummaryText: (text: string) => void +} + +enum StylesNamePostfix { + Dark = '/dark_theme.css', + Light = '/light_theme.css', + Global = '/global_styles.css' +} + +const baseUrl = getBaseApiUrl() + +const QueryCardCliPlugin = (props: Props) => { + const { result, query, id, status, setSummaryText } = props + const { visualizations = [], staticPath } = useSelector(appPluginsSelector) + const { modules = [] } = useSelector(connectedInstanceSelector) + + const [currentView, setCurrentView] = useState>(null) + const [currentPlugin, setCurrentPlugin] = useState>(null) + const [isPluginLoaded, setIsPluginLoaded] = useState(false) + const [error, setError] = useState('') + const pluginIframeRef = useRef>(null) + const prevPluginHeightRef = useRef('0') + const generatedIframeNameRef = useRef('') + const { theme } = useContext(ThemeContext) + + const dispatch = useDispatch() + + const executeCommand = () => { + pluginIframeRef?.current?.contentWindow?.postMessage({ + event: 'executeCommand', + method: currentView.activationMethod, + data: { command: query, data: result, status } + }, '*') + } + + const sendRedisCommand = (command: string, requestId: string) => { + dispatch( + sendPluginCommandAction({ + command, + onSuccessAction: (response) => { + pluginIframeRef?.current?.contentWindow?.postMessage({ + event: 'executeRedisCommand', + requestId, + data: response + }, '*') + } + }) + ) + } + + useEffect(() => { + if (currentView === null) return + pluginApi.onEvent(generatedIframeNameRef.current, PluginEvents.heightChanged, (height: string) => { + if (pluginIframeRef?.current) { + pluginIframeRef.current.height = height || prevPluginHeightRef.current + prevPluginHeightRef.current = height + } + }) + + pluginApi.onEvent(generatedIframeNameRef.current, PluginEvents.loaded, () => { + setIsPluginLoaded(true) + setError('') + executeCommand() + }) + + pluginApi.onEvent(generatedIframeNameRef.current, PluginEvents.error, (error: string) => { + setIsPluginLoaded(true) + setError(error) + }) + + pluginApi.onEvent(generatedIframeNameRef.current, PluginEvents.setHeaderText, (text: string) => { + setSummaryText(text) + }) + + // pluginApi.onEvent( + // generatedIframeNameRef.current, + // 'executeRedisCommand', + // sendRedisCommand + // ) + }, [currentView]) + + const renderPluginIframe = (config: any) => { + const html = prepareIframeHtml({ + ...config, + bodyClass: theme === Theme.Dark ? 'theme_DARK' : 'theme_LIGHT', + modules + }) + // @ts-ignore + pluginIframeRef.current.src = `data:text/html;charset=utf-8,${encodeURI(html)}` + } + + const getGlobalStylesSrc = (): string => + `${baseUrl}${staticPath}${StylesNamePostfix.Global}` + + const getThemeSrc = (): string => + `${baseUrl}${staticPath}${theme === Theme.Dark ? StylesNamePostfix.Dark : StylesNamePostfix.Light}` + + const generateStylesSrc = (styles: string): string[] => { + const themeSrc = getThemeSrc() + const globalSrc = getGlobalStylesSrc() + + return [globalSrc, themeSrc, `${baseUrl}${styles}`] + } + + useEffect(() => { + const view = visualizations.find((visualization: IPluginVisualization) => visualization.uniqId === id) + if (view) { + generatedIframeNameRef.current = `${view.plugin.name}-${Date.now()}` + setCurrentView(view) + + const { plugin } = view + if (plugin?.name !== currentPlugin) { + renderPluginIframe({ + baseUrl: `${baseUrl}${plugin.baseUrl}`, + scriptPath: plugin.scriptSrc, + scriptSrc: `${baseUrl}${plugin.scriptSrc}`, + stylesSrc: generateStylesSrc(plugin.stylesSrc), + iframeId: generatedIframeNameRef.current, + }) + setCurrentPlugin(plugin?.name || null) + return + } + executeCommand() + } + }, [result]) + + return ( +
+
+