From 51fdcbfa50eae4b79a27d4c4cbad2988508ab457 Mon Sep 17 00:00:00 2001 From: Zalenski Egor <63463140+zalenskiSofteq@users.noreply.github.com> Date: Tue, 16 Nov 2021 14:44:00 +0300 Subject: [PATCH] Initial commit --- .circleci/config.yml | 971 + .circleci/deps-audit-report.js | 83 + .circleci/e2e-results.js | 50 + .circleci/itest-results.js | 51 + .circleci/lint-report.js | 62 + .dockerignore | 20 + .editorconfig | 16 + .eslintignore | 56 + .eslintrc.js | 17 + .gitattributes | 12 + .github/CODEOWNERS | 3 + .gitignore | 59 + .yarnrc | 4 + CHANGELOG.md | 4 + Dockerfile | 77 + LICENSE | 557 + README.md | 129 + api-docker-entry.sh | 14 + api.Dockerfile | 42 + babel.config.js | 55 + configs/.eslintrc | 7 + configs/paths.js | 17 + configs/webpack.config.base.js | 83 + configs/webpack.config.eslint.js | 5 + configs/webpack.config.main.prod.babel.js | 83 + configs/webpack.config.main.stage.babel.js | 32 + configs/webpack.config.renderer.dev.babel.js | 270 + .../webpack.config.renderer.dev.dll.babel.js | 69 + configs/webpack.config.renderer.prod.babel.js | 220 + .../webpack.config.renderer.stage.babel.js | 27 + configs/webpack.config.web.common.babel.js | 116 + configs/webpack.config.web.dev.babel.js | 204 + configs/webpack.config.web.prod.babel.js | 165 + docker-entry.sh | 24 + electron-builder.json | 70 + package.json | 256 + redisinsight/__mocks__/fileMock.js | 1 + redisinsight/__mocks__/monacoMock.js | 5 + redisinsight/about-panel.ts | 13 + redisinsight/api/.dockerignore | 11 + redisinsight/api/.eslintignore | 4 + redisinsight/api/.eslintrc.js | 21 + redisinsight/api/.gitignore | 41 + redisinsight/api/.prettierignore | 2 + redisinsight/api/.prettierrc | 4 + redisinsight/api/.yarnclean.prod | 3 + redisinsight/api/README.md | 63 + redisinsight/api/config/default.ts | 89 + redisinsight/api/config/development.ts | 13 + redisinsight/api/config/logger.ts | 63 + redisinsight/api/config/ormconfig.ts | 31 + redisinsight/api/config/production.ts | 23 + redisinsight/api/config/staging.ts | 24 + redisinsight/api/config/swagger.ts | 13 + redisinsight/api/config/test.ts | 18 + .../1614164490968-initial-migration.ts | 26 + .../1615480887019-connection-type.ts | 20 + ...15990079125-database-name-from-provider.ts | 20 + .../1615992183565-remove-database-type.ts | 20 + .../migration/1616520395940-oss-sentinel.ts | 20 + .../api/migration/1625771635418-agreements.ts | 14 + .../migration/1626086601057-server-info.ts | 14 + ...1626904405170-database-hosting-provider.ts | 20 + .../api/migration/1627556171227-settings.ts | 14 + .../1629729923740-database-modules.ts | 20 + .../1634219846022-database-db-index.ts | 20 + .../api/migration/1634557312500-encryption.ts | 52 + redisinsight/api/migration/index.ts | 27 + redisinsight/api/nest-cli.json | 9 + redisinsight/api/package.json | 132 + redisinsight/api/package.tmp.json | 65 + redisinsight/api/src/__mocks__/analytics.ts | 39 + .../api/src/__mocks__/app-settings.ts | 42 + .../api/src/__mocks__/autodiscovery-tools.ts | 61 + .../api/src/__mocks__/certificates.ts | 44 + redisinsight/api/src/__mocks__/commands.ts | 169 + redisinsight/api/src/__mocks__/common.ts | 47 + redisinsight/api/src/__mocks__/encryption.ts | 23 + redisinsight/api/src/__mocks__/errors.ts | 31 + redisinsight/api/src/__mocks__/index.ts | 10 + .../api/src/__mocks__/redis-databases.ts | 87 + redisinsight/api/src/__mocks__/redis-info.ts | 132 + redisinsight/api/src/app.module.ts | 80 + redisinsight/api/src/app.routes.ts | 31 + .../api/src/constants/agreements-spec.json | 56 + redisinsight/api/src/constants/app-events.ts | 4 + .../api/src/constants/commands/main.json | 5901 ++++++ .../api/src/constants/commands/redijson.json | 59 + .../api/src/constants/commands/redisai.json | 420 + .../src/constants/commands/redisearch.json | 34 + .../src/constants/commands/redisgraph.json | 32 + .../constants/commands/redistimeseries.json | 127 + .../api/src/constants/error-messages.ts | 45 + redisinsight/api/src/constants/exceptions.ts | 28 + redisinsight/api/src/constants/index.ts | 11 + .../api/src/constants/redis-commands.ts | 45 + .../api/src/constants/redis-connection.ts | 1 + .../api/src/constants/redis-error-codes.ts | 21 + redisinsight/api/src/constants/redis-keys.ts | 1 + .../api/src/constants/redis-modules.ts | 49 + redisinsight/api/src/constants/regex.ts | 5 + redisinsight/api/src/constants/sort.ts | 4 + .../api/src/constants/telemetry-events.ts | 49 + .../src/controllers/server-info.controller.ts | 75 + .../src/controllers/settings.controller.ts | 83 + .../src/decorators/api-endpoint.decorator.ts | 20 + .../api-redis-instance-operation.decorator.ts | 12 + .../decorators/api-redis-params.decorator.ts | 13 + .../api/src/dto/dto-transformer.spec.ts | 14 + redisinsight/api/src/dto/dto-transformer.ts | 14 + redisinsight/api/src/dto/server.dto.ts | 45 + redisinsight/api/src/dto/settings.dto.ts | 115 + redisinsight/api/src/main.ts | 54 + .../middleware/redis-connection.middleware.ts | 44 + .../api/src/models/agreements.interface.ts | 15 + redisinsight/api/src/models/index.ts | 4 + redisinsight/api/src/models/redis-client.ts | 22 + redisinsight/api/src/models/redis-cluster.ts | 29 + .../src/models/redis-consumer.interface.ts | 17 + .../api/src/modules/browser/browser.module.ts | 61 + .../constants/browser-tool-commands.ts | 94 + .../controllers/hash/hash.controller.ts | 110 + .../controllers/keys/keys.controller.ts | 146 + .../controllers/list/list.controller.ts | 186 + .../rejson-rl/rejson-rl.controller.ts | 122 + .../browser/controllers/set/set.controller.ts | 110 + .../controllers/string/string.controller.ts | 84 + .../controllers/z-set/z-set.controller.ts | 157 + .../api/src/modules/browser/dto/hash.dto.ts | 106 + .../api/src/modules/browser/dto/index.ts | 7 + .../api/src/modules/browser/dto/keys.dto.ts | 301 + .../api/src/modules/browser/dto/list.dto.ts | 199 + .../src/modules/browser/dto/rejson-rl.dto.ts | 181 + .../api/src/modules/browser/dto/set.dto.ts | 79 + .../api/src/modules/browser/dto/string.dto.ts | 33 + .../api/src/modules/browser/dto/z-set.dto.ts | 186 + .../browser-analytics.service.spec.ts | 435 + .../browser-analytics.service.ts | 253 + .../browser-tool-cluster.service.spec.ts | 235 + .../browser-tool-cluster.service.ts | 133 + .../browser-tool/browser-tool.service.spec.ts | 151 + .../browser-tool/browser-tool.service.ts | 59 + .../hash-business.service.spec.ts | 472 + .../hash-business/hash-business.service.ts | 315 + .../key-info-manager.interface.ts | 10 + .../key-info-manager/key-info-manager.spec.ts | 51 + .../key-info-manager/key-info-manager.ts | 23 + .../graph-type-info.strategy.spec.ts | 158 + .../graph-type-info.strategy.ts | 67 + .../hash-type-info.strategy.spec.ts | 124 + .../hash-type-info/hash-type-info.strategy.ts | 51 + .../list-type-info.strategy.spec.ts | 124 + .../list-type-info/list-type-info.strategy.ts | 51 + .../rejson-rl-type-info.strategy.spec.ts | 194 + .../rejson-rl-type-info.strategy.ts | 90 + .../set-type-info.strategy.spec.ts | 124 + .../set-type-info/set-type-info.strategy.ts | 51 + .../stream-type-info.strategy.spec.ts | 124 + .../stream-type-info.strategy.ts | 51 + .../string-type-info.strategy.spec.ts | 124 + .../string-type-info.strategy.ts | 51 + .../ts-type-info.strategy.spec.ts | 158 + .../ts-type-info/ts-type-info.strategy.ts | 69 + .../unsupported-type-info.strategy.spec.ts | 115 + .../unsupported-type-info.strategy.ts | 46 + .../z-set-type-info.strategy.spec.ts | 124 + .../z-set-type-info.strategy.ts | 51 + .../keys-business.service.spec.ts | 439 + .../keys-business/keys-business.service.ts | 345 + .../scanner/scanner.interface.ts | 25 + .../keys-business/scanner/scanner.spec.ts | 101 + .../services/keys-business/scanner/scanner.ts | 19 + .../strategies/abstract.strategy.spec.ts | 165 + .../scanner/strategies/abstract.strategy.ts | 103 + .../strategies/cluster.strategy.spec.ts | 1015 ++ .../scanner/strategies/cluster.strategy.ts | 207 + .../strategies/standalone.strategy.spec.ts | 509 + .../scanner/strategies/standalone.strategy.ts | 107 + .../list-business.service.spec.ts | 605 + .../list-business/list-business.service.ts | 347 + .../rejson-rl-business.service.spec.ts | 1156 ++ .../rejson-rl-business.service.ts | 501 + .../set-business/set-business.service.spec.ts | 492 + .../set-business/set-business.service.ts | 308 + .../string-business.service.spec.ts | 246 + .../string-business.service.ts | 148 + .../z-set-business.service.spec.ts | 697 + .../z-set-business/z-set-business.service.ts | 474 + .../api/src/modules/cli/cli.module.ts | 26 + .../api/src/modules/cli/constants/errors.ts | 36 + .../modules/cli/controllers/cli.controller.ts | 138 + .../decorators/api-cli-params.decorator.ts | 26 + .../api/src/modules/cli/dto/cli.dto.ts | 153 + .../cli-analytics.service.spec.ts | 313 + .../cli-analytics/cli-analytics.service.ts | 145 + .../cli-business/cli-business.service.spec.ts | 713 + .../cli-business/cli-business.service.ts | 345 + .../output-formatter-manager.spec.ts | 51 + .../output-formatter-manager.ts | 23 + .../output-formatter.interface.ts | 13 + .../strategies/raw-formatter.strategy.spec.ts | 75 + .../strategies/raw-formatter.strategy.ts | 43 + .../text-formatter.strategy.spec.ts | 95 + .../strategies/text-formatter.strategy.ts | 97 + .../cli-tool/cli-tool.service.spec.ts | 74 + .../cli/services/cli-tool/cli-tool.service.ts | 202 + .../commands/commands-json.provider.spec.ts | 85 + .../commands/commands-json.provider.ts | 75 + .../modules/commands/commands.controller.ts | 16 + .../src/modules/commands/commands.module.ts | 69 + .../modules/commands/commands.service.spec.ts | 84 + .../src/modules/commands/commands.service.ts | 34 + .../api/src/modules/core/core.module.ts | 65 + .../encryption/encryption.service.spec.ts | 112 + .../core/encryption/encryption.service.ts | 79 + .../encryption-service-error.exception.ts | 11 + .../core/encryption/exceptions/index.ts | 5 + .../keytar-decryption-error.exception.ts | 13 + .../keytar-encryption-error.exception.ts | 13 + .../keytar-unavailable.exception.ts | 13 + ...supported-encryption-strategy.exception.ts | 13 + .../encryption/models/encryption-result.ts | 10 + .../modules/core/encryption/models/index.ts | 1 + .../encryption-strategy.interface.ts | 7 + .../keytar-encryption.strategy.spec.ts | 141 + .../strategies/keytar-encryption.strategy.ts | 140 + .../plain-encryption.strategy.spec.ts | 43 + .../strategies/plain-encryption.strategy.ts | 21 + .../core/interceptors/timeout.interceptor.ts | 38 + .../modules/core/models/agreements.entity.ts | 38 + .../core/models/ca-certificate.entity.ts | 38 + .../core/models/client-certificate.entity.ts | 42 + .../core/models/database-instance.entity.ts | 198 + .../core/models/server-provider.interface.ts | 5 + .../src/modules/core/models/server.entity.ts | 10 + .../models/settings-provider.interface.ts | 13 + .../modules/core/models/settings.entity.ts | 39 + .../core/providers/server-on-premise/index.ts | 14 + .../server-on-premise.service.spec.ts | 169 + .../server-on-premise.service.ts | 97 + .../providers/settings-on-premise/index.ts | 26 + .../settings-on-premise.service.spec.ts | 270 + .../settings-on-premise.service.ts | 193 + .../repositories/agreements.repository.ts | 5 + .../base/base.interface.repository.ts | 9 + .../core/repositories/server.repository.ts | 5 + .../core/repositories/settings.repository.ts | 5 + .../analytics/analytics.service.spec.ts | 122 + .../services/analytics/analytics.service.ts | 76 + .../ca-cert-business.service.spec.ts | 148 + .../ca-cert-business.service.ts | 144 + .../client-cert-business.service.spec.ts | 153 + .../client-cert-business.service.ts | 166 + .../core/services/redis/redis.service.spec.ts | 441 + .../core/services/redis/redis.service.ts | 381 + .../settings-analytics.service.spec.ts | 122 + .../settings-analytics.service.ts | 69 + .../certificates/certificates.controller.ts | 69 + .../instances/instances.controller.ts | 354 + .../instances/dto/database-instance.dto.ts | 434 + .../instances/dto/database-overview.dto.ts | 51 + .../dto/redis-enterprise-cloud.dto.ts | 87 + .../dto/redis-enterprise-cluster.dto.ts | 60 + .../modules/instances/dto/redis-info.dto.ts | 92 + .../instances/dto/redis-sentinel.dto.ts | 120 + .../src/modules/instances/instances.module.ts | 24 + .../src/modules/plugin/plugin.controller.ts | 28 + .../api/src/modules/plugin/plugin.module.ts | 10 + .../api/src/modules/plugin/plugin.response.ts | 134 + .../api/src/modules/plugin/plugin.service.ts | 64 + .../controllers/cloud.controller.ts | 91 + .../controllers/cluster.controller.ts | 47 + .../modules/redis-enterprise/dto/cloud.dto.ts | 203 + .../redis-enterprise/dto/cluster.dto.ts | 115 + .../models/redis-cloud-account.ts | 22 + .../models/redis-cloud-database.ts | 87 + .../models/redis-cloud-subscriptions.ts | 48 + .../models/redis-enterprise-database.ts | 147 + .../redis-enterprise.module.ts | 11 + .../utils/redis-cloud-converter.spec.ts | 19 + .../utils/redis-cloud-converter.ts | 5 + .../utils/redis-enterprise-converter.spec.ts | 19 + .../utils/redis-enterprise-converter.ts | 5 + .../controllers/sentinel.controller.ts | 49 + .../redis-sentinel/dto/sentinel.dto.ts | 53 + .../modules/redis-sentinel/models/sentinel.ts | 62 + .../redis-sentinel/redis-sentinel.module.ts | 10 + .../autodiscovery-analytics.service.spec.ts | 415 + .../autodiscovery-analytics.service.ts | 100 + .../redis-consumer.abstract.service.spec.ts | 206 + .../base/redis-consumer.abstract.service.ts | 145 + .../base/telemetry.base.service.spec.ts | 109 + .../services/base/telemetry.base.service.ts | 36 + .../configuration-business.service.spec.ts | 372 + .../configuration-business.service.ts | 216 + .../database.provider.spec.ts | 195 + .../instances-business/databases.provider.ts | 196 + .../instances-analytics.service.spec.ts | 236 + .../instances-analytics.service.ts | 102 + .../instances-business.service.spec.ts | 850 + .../instances-business.service.ts | 686 + .../overview.service.spec.ts | 249 + .../instances-business/overview.service.ts | 244 + .../redis-cloud-business.service.spec.ts | 489 + .../redis-cloud-business.service.ts | 333 + .../redis-enterprise-business.service.spec.ts | 311 + .../redis-enterprise-business.service.ts | 176 + .../redis-sentinel-business.service.spec.ts | 168 + .../redis-sentinel-business.service.ts | 118 + .../api/src/modules/shared/shared.module.ts | 48 + .../shared/utils/database-entity-converter.ts | 45 + .../api/src/utils/analytics-helper.spec.ts | 97 + .../api/src/utils/analytics-helper.ts | 80 + .../api/src/utils/catch-redis-errors.ts | 143 + redisinsight/api/src/utils/cli-helper.spec.ts | 280 + redisinsight/api/src/utils/cli-helper.ts | 229 + redisinsight/api/src/utils/config.spec.ts | 61 + redisinsight/api/src/utils/config.ts | 28 + redisinsight/api/src/utils/converter.spec.ts | 44 + redisinsight/api/src/utils/converter.ts | 26 + .../api/src/utils/glob-pattern-helper.spec.ts | 32 + .../api/src/utils/glob-pattern-helper.ts | 13 + .../src/utils/hosting-provider-helper.spec.ts | 34 + .../api/src/utils/hosting-provider-helper.ts | 22 + redisinsight/api/src/utils/index.ts | 8 + redisinsight/api/src/utils/logsFormatter.ts | 54 + .../api/src/utils/redis-connection-helper.ts | 22 + .../src/utils/redis-reply-converter.spec.ts | 119 + .../api/src/utils/redis-reply-converter.ts | 74 + .../caCertCollision.validator.spec.ts | 36 + .../validators/caCertCollision.validator.ts | 16 + .../clientCertCollision.validator.spec.ts | 36 + .../clientCertCollision.validator.ts | 17 + redisinsight/api/src/validators/index.ts | 3 + .../serializedJson.validator.spec.ts | 64 + .../validators/serializedJson.validator.ts | 20 + redisinsight/api/test/api/api.deps.init.ts | 9 + redisinsight/api/test/api/api.tsconfig.json | 20 + ...e-id-cli-uuid-send_cluster_command.test.ts | 278 + ...-instance-id-cli-uuid-send_command.test.ts | 919 + .../test/api/cli/POST-instance-id-cli.test.ts | 53 + ...redis_enterprise-cloud-get_account.test.ts | 88 + ...dis_enterprise-cloud-get_databases.test.ts | 116 + ...enterprise-cloud-get_subscriptions.test.ts | 95 + .../test/api/commands/GET-commands.test.ts | 39 + redisinsight/api/test/api/deps.ts | 38 + ...T-redis-enterprise-cluster-get_dbs.test.ts | 69 + .../DELETE-instance-id-hash-fields.test.ts | 223 + .../POST-instance-id-hash-get_fields.test.ts | 290 + .../api/hash/POST-instance-id-hash.test.ts | 212 + .../api/hash/PUT-instance-id-hash.test.ts | 180 + .../GET-info-cli-blocking-commands.test.ts | 42 + .../GET-info-cli-unsupported-commands.test.ts | 33 + .../api/test/api/info/GET-info.test.ts | 43 + .../api/instance/DELETE-instance-id.test.ts | 63 + .../test/api/instance/DELETE-instance.test.ts | 88 + .../instance/GET-instance-id-connect.test.ts | 45 + .../api/instance/GET-instance-id-info.test.ts | 71 + .../instance/GET-instance-id-overview.test.ts | 59 + .../GET-instance-id-plugin-commands.test.ts | 52 + .../test/api/instance/GET-instance.test.ts | 70 + .../instance/PATCH-instance-id-name.test.ts | 95 + .../POST-instance-sentinel_masters.test.ts | 110 + .../test/api/instance/POST-instance.test.ts | 549 + .../test/api/instance/PUT-instance-id.test.ts | 116 + .../api/keys/DELETE-instance-id-keys.test.ts | 204 + .../api/keys/GET-instance-id-keys.test.ts | 865 + .../keys/PATCH-instance-id-keys-name.test.ts | 199 + .../keys/PATCH-instance-id-keys-ttl.test.ts | 163 + .../POST-instance-id-keys-get_info.test.ts | 292 + .../DELETE-instance-id-list-elements.test.ts | 234 + .../api/list/PATCH-instance-id-list.test.ts | 183 + ...nstance-id-list-get_elements-index.test.ts | 196 + ...POST-instance-id-list-get_elements.test.ts | 193 + .../api/list/POST-instance-id-list.test.ts | 177 + .../api/list/PUT-instance-id-list.test.ts | 200 + .../api/test/api/plugins/GET-plugins.test.ts | 46 + .../DELETE-instance-id-rejson_rl.test.ts | 180 + ...CH-instance-id-rejson_rl-arrappend.test.ts | 160 + .../PATCH-instance-id-rejson_rl-set.test.ts | 174 + .../POST-instance-id-rejson_rl-get.test.ts | 305 + .../POST-instance-id-rejson_rl.test.ts | 196 + redisinsight/api/test/api/reporters.json | 9 + .../DELETE-instance-id-set-members.test.ts | 197 + .../POST-instance-id-set-get_members.test.ts | 283 + .../test/api/set/POST-instance-id-set.test.ts | 187 + .../test/api/set/PUT-instance-id-set.test.ts | 184 + .../GET-settings-agreements-spec.test.ts | 59 + .../test/api/settings/GET-settings.test.ts | 70 + .../test/api/settings/PATCH-settings.test.ts | 166 + .../string/POST-instance-id-string.test.ts | 179 + .../api/string/PUT-instance-id-string.test.ts | 184 + .../DELETE-instance-id-zSet-members.test.ts | 192 + .../api/z-set/PATCH-instance-id-zSet.test.ts | 197 + .../POST-instance-id-zSet-get_members.test.ts | 237 + .../POST-instance-id-zSet-search.test.ts | 275 + .../api/z-set/POST-instance-id-zSet.test.ts | 214 + .../api/z-set/PUT-instance-id-zSet.test.ts | 223 + redisinsight/api/test/helpers/cloud.ts | 155 + redisinsight/api/test/helpers/constants.ts | 185 + redisinsight/api/test/helpers/data/redis.ts | 279 + redisinsight/api/test/helpers/local-db.ts | 293 + redisinsight/api/test/helpers/redis.ts | 212 + redisinsight/api/test/helpers/server.ts | 44 + redisinsight/api/test/helpers/test.ts | 178 + .../test/helpers/test/conditionalIgnore.ts | 100 + .../api/test/helpers/test/dataGenerator.ts | 131 + redisinsight/api/test/helpers/utils.ts | 51 + redisinsight/api/test/test-runs/cloud-st/.env | 1 + .../test-runs/cloud-st/docker-compose.yml | 12 + .../api/test/test-runs/docker.build.env | 6 + .../api/test/test-runs/docker.build.yml | 45 + .../api/test/test-runs/local.build.env | 4 + .../api/test/test-runs/local.build.yml | 27 + .../test-runs/mods-preview/docker-compose.yml | 8 + .../api/test/test-runs/oss-clu-tls/.env | 1 + .../api/test/test-runs/oss-clu-tls/Dockerfile | 13 + .../test-runs/oss-clu-tls/certs/redis.crt | 28 + .../test-runs/oss-clu-tls/certs/redis.key | 52 + .../test-runs/oss-clu-tls/certs/redisCA.crt | 30 + .../test-runs/oss-clu-tls/docker-compose.yml | 32 + redisinsight/api/test/test-runs/oss-clu/.env | 1 + .../api/test/test-runs/oss-clu/Dockerfile | 3 + .../test/test-runs/oss-clu/docker-compose.yml | 44 + redisinsight/api/test/test-runs/oss-sent/.env | 3 + .../api/test/test-runs/oss-sent/Dockerfile | 15 + .../test-runs/oss-sent/docker-compose.yml | 26 + .../api/test/test-runs/oss-sent/entrypoint.sh | 10 + .../api/test/test-runs/oss-sent/sentinel.conf | 9 + .../api/test/test-runs/oss-st-5-pass/.env | 1 + .../oss-st-5-pass/docker-compose.yml | 9 + .../api/test/test-runs/oss-st-5/Dockerfile | 14 + .../test-runs/oss-st-5/docker-compose.yml | 5 + .../api/test/test-runs/oss-st-5/redis.conf | 4 + .../api/test/test-runs/oss-st-6-tls-auth/.env | 3 + .../test-runs/oss-st-6-tls-auth/Dockerfile | 13 + .../oss-st-6-tls-auth/certs/redis.crt | 28 + .../oss-st-6-tls-auth/certs/redis.key | 52 + .../oss-st-6-tls-auth/certs/redisCA.crt | 30 + .../oss-st-6-tls-auth/certs/user.crt | 28 + .../oss-st-6-tls-auth/certs/user.key | 52 + .../oss-st-6-tls-auth/docker-compose.yml | 10 + .../api/test/test-runs/oss-st-6-tls/.env | 1 + .../test/test-runs/oss-st-6-tls/Dockerfile | 13 + .../test-runs/oss-st-6-tls/certs/redis.crt | 28 + .../test-runs/oss-st-6-tls/certs/redis.key | 52 + .../test-runs/oss-st-6-tls/certs/redisCA.crt | 30 + .../test-runs/oss-st-6-tls/docker-compose.yml | 10 + .../test-runs/oss-st-6/docker-compose.yml | 7 + redisinsight/api/test/test-runs/re-clu/.env | 4 + .../api/test/test-runs/re-clu/Dockerfile | 18 + .../api/test/test-runs/re-clu/README.md | 18 + .../api/test/test-runs/re-clu/cert.pem | 32 + .../api/test/test-runs/re-clu/create_dbs.py | 218 + .../test/test-runs/re-clu/docker-compose.yml | 17 + .../test-runs/re-clu/run_re_and_create_db.sh | 39 + redisinsight/api/test/test-runs/re-st/.env | 4 + .../api/test/test-runs/re-st/Dockerfile | 18 + .../api/test/test-runs/re-st/README.md | 18 + .../api/test/test-runs/re-st/cert.pem | 32 + .../api/test/test-runs/re-st/create_dbs.py | 215 + .../test/test-runs/re-st/docker-compose.yml | 17 + .../test-runs/re-st/run_re_and_create_db.sh | 39 + redisinsight/api/test/test-runs/run-all.sh | 6 + .../api/test/test-runs/start-test-run.sh | 70 + .../api/test/test-runs/test-docker-entry.sh | 14 + .../api/test/test-runs/test.Dockerfile | 20 + .../api/test/test-runs/wait-for-it.sh | 182 + redisinsight/api/tsconfig.build.json | 12 + redisinsight/api/tsconfig.build.prod.json | 16 + redisinsight/api/tsconfig.json | 27 + redisinsight/api/yarn.lock | 8646 +++++++++ redisinsight/index.html | 47 + redisinsight/main.dev.ts | 359 + redisinsight/main.prod.js.LICENSE.txt | 327 + redisinsight/main.renderer.ts | 8 + redisinsight/menu.ts | 287 + redisinsight/package.json | 19 + redisinsight/tray.ts | 120 + redisinsight/ui/.eslintignore | 1 + redisinsight/ui/.eslintrc.js | 64 + redisinsight/ui/README.md | 0 redisinsight/ui/index.html.ejs | 23 + redisinsight/ui/index.tsx | 11 + redisinsight/ui/indexElectron.tsx | 11 + redisinsight/ui/src/App.scss | 57 + redisinsight/ui/src/App.spec.tsx | 10 + redisinsight/ui/src/App.tsx | 42 + redisinsight/ui/src/Router.spec.tsx | 16 + redisinsight/ui/src/Router.tsx | 16 + redisinsight/ui/src/assets/assets.d.ts | 19 + redisinsight/ui/src/assets/favicon.ico | Bin 0 -> 1086 bytes .../assets/fonts/graphik/Graphik-Light.otf | Bin 0 -> 134752 bytes .../fonts/graphik/Graphik-LightItalic.otf | Bin 0 -> 139012 bytes .../assets/fonts/graphik/Graphik-Medium.otf | Bin 0 -> 137664 bytes .../fonts/graphik/Graphik-MediumItalic.otf | Bin 0 -> 140808 bytes .../assets/fonts/graphik/Graphik-Regular.otf | Bin 0 -> 131204 bytes .../fonts/graphik/Graphik-RegularItalic.otf | Bin 0 -> 134832 bytes .../assets/fonts/graphik/Graphik-Semibold.otf | Bin 0 -> 138448 bytes .../fonts/graphik/Graphik-SemiboldItalic.otf | Bin 0 -> 142280 bytes .../fonts/inconsolata/Inconsolata-Bold.ttf | Bin 0 -> 98260 bytes .../fonts/inconsolata/Inconsolata-Regular.ttf | Bin 0 -> 97864 bytes .../ui/src/assets/img/active_auto.svg | 25 + .../ui/src/assets/img/active_manual.svg | 48 + redisinsight/ui/src/assets/img/dark_logo.svg | 112 + redisinsight/ui/src/assets/img/light_logo.svg | 113 + .../assets/img/light_theme/active_auto.svg | 25 + .../assets/img/light_theme/active_manual.svg | 48 + .../assets/img/light_theme/n_active_auto.svg | 24 + .../img/light_theme/n_active_manual.svg | 47 + redisinsight/ui/src/assets/img/logo.svg | 14 + .../ui/src/assets/img/modules/RedisAIDark.svg | 1 + .../src/assets/img/modules/RedisAILight.svg | 1 + .../src/assets/img/modules/RedisBloomDark.svg | 1 + .../assets/img/modules/RedisBloomLight.svg | 1 + .../src/assets/img/modules/RedisGearsDark.svg | 1 + .../assets/img/modules/RedisGearsLight.svg | 1 + .../src/assets/img/modules/RedisGraphDark.svg | 1 + .../assets/img/modules/RedisGraphLight.svg | 1 + .../src/assets/img/modules/RedisJSONDark.svg | 1 + .../src/assets/img/modules/RedisJSONLight.svg | 1 + .../assets/img/modules/RedisSearchDark.svg | 1 + .../assets/img/modules/RedisSearchLight.svg | 1 + .../img/modules/RedisTimeSeriesDark.svg | 1 + .../img/modules/RedisTimeSeriesLight.svg | 1 + .../ui/src/assets/img/modules/UnknownDark.svg | 21 + .../src/assets/img/modules/UnknownLight.svg | 21 + .../ui/src/assets/img/not_active_auto.svg | 24 + .../ui/src/assets/img/not_active_manual.svg | 47 + .../assets/img/options/Active-ActiveDark.svg | 1 + .../assets/img/options/Active-ActiveLight.svg | 1 + .../assets/img/options/RedisOnFlashDark.svg | 3 + .../assets/img/options/RedisOnFlashLight.svg | 1 + .../src/assets/img/overview/input_light.svg | 25 + .../ui/src/assets/img/overview/input_tip.svg | 25 + .../ui/src/assets/img/overview/key_dark.svg | 17 + .../ui/src/assets/img/overview/key_light.svg | 17 + .../ui/src/assets/img/overview/key_tip.svg | 17 + .../src/assets/img/overview/measure_dark.svg | 17 + .../src/assets/img/overview/measure_light.svg | 17 + .../src/assets/img/overview/measure_tip.svg | 17 + .../src/assets/img/overview/memory_dark.svg | 20 + .../src/assets/img/overview/memory_light.svg | 20 + .../src/assets/img/overview/output_light.svg | 25 + .../ui/src/assets/img/overview/output_tip.svg | 25 + .../ui/src/assets/img/overview/time_dark.svg | 17 + .../ui/src/assets/img/overview/time_light.svg | 17 + .../ui/src/assets/img/overview/time_tip.svg | 17 + .../ui/src/assets/img/overview/user_dark.svg | 17 + .../ui/src/assets/img/overview/user_light.svg | 17 + .../ui/src/assets/img/overview/user_tip.svg | 17 + .../ui/src/assets/img/resize-corner.svg | 16 + .../ui/src/assets/img/sidebar/browser.svg | 17 + .../src/assets/img/sidebar/browser_active.svg | 17 + .../ui/src/assets/img/sidebar/database.svg | 22 + .../assets/img/sidebar/database_active.svg | 22 + .../ui/src/assets/img/sidebar/settings.svg | 32 + .../assets/img/sidebar/settings_active.svg | 32 + .../ui/src/assets/img/sidebar/workbench.svg | 13 + .../assets/img/sidebar/workbench_active.svg | 15 + .../ui/src/assets/img/welcome_bg_dark.jpg | Bin 0 -> 126550 bytes .../ui/src/assets/img/welcome_bg_light.jpg | Bin 0 -> 730105 bytes .../workbench/RediSearchNotAvailableDark.jpg | Bin 0 -> 24148 bytes .../workbench/RediSearchNotAvailableLight.jpg | Bin 0 -> 30211 bytes .../img/workbench/default_view_dark.svg | 17 + .../img/workbench/default_view_light.svg | 17 + .../img/workbench/table_view_icon_dark.svg | 11 + .../img/workbench/table_view_icon_light.svg | 11 + .../ui/src/components/CircularSpinnerPage.tsx | 34 + .../ui/src/components/ContentEditable.tsx | 73 + .../components/action-bar/ActionBar.spec.tsx | 25 + .../src/components/action-bar/ActionBar.tsx | 49 + .../components/action-bar/styles.module.scss | 49 + .../AdvancedSettings.spec.tsx | 74 + .../advanced-settings/AdvancedSettings.tsx | 127 + .../advanced-settings/styles.module.scss | 30 + .../ui/src/components/cli/Cli/Cli.spec.tsx | 21 + .../ui/src/components/cli/Cli/Cli.tsx | 16 + .../ui/src/components/cli/Cli/index.ts | 3 + .../src/components/cli/Cli/styles.module.scss | 22 + .../ui/src/components/cli/CliWrapper.spec.tsx | 41 + .../ui/src/components/cli/CliWrapper.tsx | 7 + .../cli-body/CliBody/CliBody.spec.tsx | 331 + .../components/cli-body/CliBody/CliBody.tsx | 253 + .../cli/components/cli-body/CliBody/index.ts | 3 + .../cli-body/CliBody/styles.module.scss | 56 + .../cli-body/CliBodyWrapper.spec.tsx | 164 + .../components/cli-body/CliBodyWrapper.tsx | 155 + .../cli/components/cli-body/index.ts | 3 + .../cli-command-info/CliCommandInfo.tsx | 34 + .../cli/components/cli-command-info/index.ts | 3 + .../cli-command-info/styles.module.scss | 17 + .../CliHeaderMinimized.spec.tsx | 27 + .../CliHeaderMinimized.tsx | 60 + .../components/cli-header-minimized/index.ts | 3 + .../components/cli-header/CliHeader.spec.tsx | 118 + .../cli/components/cli-header/CliHeader.tsx | 144 + .../cli/components/cli-header/index.ts | 3 + .../components/cli-header/styles.module.scss | 45 + .../cli-helper/CliHelper/CliHelper.spec.tsx | 150 + .../cli-helper/CliHelper/CliHelper.tsx | 117 + .../components/cli-helper/CliHelper/index.ts | 3 + .../cli-helper/CliHelper/styles.module.scss | 76 + .../cli-helper/CliHelperWrapper.spec.tsx | 195 + .../cli-helper/CliHelperWrapper.tsx | 107 + .../cli/components/cli-helper/index.ts | 3 + .../CliAutocomplete/CliAutocomplete.spec.tsx | 113 + .../CliAutocomplete/CliAutocomplete.tsx | 67 + .../cli-input/CliAutocomplete/index.ts | 3 + .../CliAutocomplete/styles.module.scss | 10 + .../cli-input/CliInput/CliInput.spec.tsx | 48 + .../cli-input/CliInput/CliInput.tsx | 45 + .../components/cli-input/CliInput/index.ts | 3 + .../cli-input/CliInput/styles.module.scss | 7 + .../cli-input/CliInputWrapper.spec.tsx | 60 + .../components/cli-input/CliInputWrapper.tsx | 44 + .../cli/components/cli-input/index.ts | 3 + .../CliSearchOutput.spec.tsx | 96 + .../cli-search-output/CliSearchOutput.tsx | 103 + .../cli/components/cli-search-output/index.ts | 3 + .../cli-search-output/styles.module.scss | 24 + .../CliSearchFilter/CliSearchFilter.spec.tsx | 20 + .../CliSearchFilter/CliSearchFilter.tsx | 98 + .../cli-search/CliSearchFilter/constants.ts | 72 + .../cli-search/CliSearchFilter/index.ts | 3 + .../CliSearchFilter/styles.module.scss | 83 + .../CliSearchInput/CliSearchInput.spec.tsx | 20 + .../CliSearchInput/CliSearchInput.tsx | 51 + .../cli-search/CliSearchInput/index.ts | 3 + .../CliSearchInput/styles.module.scss | 17 + .../cli-search/CliSearchWrapper.spec.tsx | 55 + .../cli-search/CliSearchWrapper.tsx | 71 + .../cli/components/cli-search/index.ts | 3 + .../components/cli-search/styles.module.scss | 4 + .../ui/src/components/config/Config.spec.tsx | 75 + .../ui/src/components/config/Config.tsx | 75 + .../ui/src/components/config/index.ts | 3 + .../ConsentsSettings.spec.tsx | 117 + .../consents-settings/ConsentsSettings.tsx | 226 + .../ConsentsSettingsPopup.spec.tsx | 9 + .../ConsentsSettingsPopup.tsx | 61 + .../src/components/consents-settings/index.ts | 4 + .../consents-settings/styles.module.scss | 47 + redisinsight/ui/src/components/css.d.ts | 4 + .../DatabaseListModules.spec.tsx | 41 + .../DatabaseListModules.tsx | 150 + .../database-list-modules/styles.module.scss | 26 + .../DatabaseListOptions.spec.tsx | 21 + .../DatabaseListOptions.tsx | 130 + .../database-list-options/styles.module.scss | 85 + .../DatabaseOverview.spec.tsx | 29 + .../database-overview/DatabaseOverview.tsx | 47 + .../components/OverviewItems.tsx | 155 + .../database-overview/components/icons.ts | 39 + .../database-overview/styles.module.scss | 72 + .../src/components/divider/Divider.spec.tsx | 12 + .../ui/src/components/divider/Divider.tsx | 26 + .../src/components/divider/styles.module.scss | 42 + .../field-message/FieldMessage.spec.tsx | 13 + .../components/field-message/FieldMessage.tsx | 46 + .../field-message/styles.module.scss | 12 + .../src/components/group-badge/GroupBadge.tsx | 23 + redisinsight/ui/src/components/index.ts | 34 + .../InlineItemEditor.spec.tsx | 37 + .../inline-item-editor/InlineItemEditor.tsx | 196 + .../inline-item-editor/styles.module.scss | 75 + .../InputFieldSentinel.spec.tsx | 46 + .../InputFieldSentinel.tsx | 97 + .../input-field-sentinel/styles.module.scss | 5 + .../instance-header/InstanceHeader.spec.tsx | 37 + .../instance-header/InstanceHeader.tsx | 247 + .../src/components/instance-header/index.ts | 3 + .../instance-header/styles.module.scss | 104 + .../KeyboardShortcut.spec.tsx | 12 + .../keyboard-shortcut/KeyboardShortcut.tsx | 25 + .../keyboard-shortcut/styles.module.scss | 17 + .../src/components/main-router/MainRouter.tsx | 20 + .../src/components/main-router/interfaces.ts | 50 + .../ui/src/components/main/MainComponent.tsx | 9 + .../message-bar/MessageBar.spec.tsx | 36 + .../src/components/message-bar/MessageBar.tsx | 50 + .../components/message-bar/styles.module.scss | 67 + .../navigation-menu/NavigationMenu.spec.tsx | 10 + .../navigation-menu/NavigationMenu.tsx | 270 + .../navigation-menu/styles.module.scss | 154 + .../notifications/Notifications.spec.tsx | 24 + .../notifications/Notifications.tsx | 104 + .../components/DefaultErrorContent.tsx | 31 + .../components/EncryptionErrorContent.tsx | 80 + .../notifications/components/index.ts | 7 + .../notifications/error-messages.tsx | 35 + .../notifications/styles.module.scss | 18 + .../notifications/success-messages.tsx | 115 + .../page-breadcrumbs/PageBreadcrumbs.spec.tsx | 38 + .../page-breadcrumbs/PageBreadcrumbs.tsx | 83 + .../src/components/page-breadcrumbs/index.ts | 3 + .../page-breadcrumbs/styles.module.scss | 69 + .../page-header/PageHeader.module.scss | 37 + .../src/components/page-header/PageHeader.tsx | 72 + .../components/query-card/QueryCard.spec.tsx | 66 + .../src/components/query-card/QueryCard.tsx | 172 + .../QueryCardCliPlugin/QueryCardCliPlugin.tsx | 182 + .../query-card/QueryCardCliPlugin/index.ts | 3 + .../QueryCardCliPlugin/styles.module.scss | 55 + .../QueryCardCliResult.spec.tsx | 64 + .../QueryCardCliResult/QueryCardCliResult.tsx | 33 + .../query-card/QueryCardCliResult/index.ts | 3 + .../QueryCardCliResult/styles.module.scss | 35 + .../QueryCardCommonResult.spec.tsx | 20 + .../QueryCardCommonResult.tsx | 30 + .../query-card/QueryCardCommonResult/index.ts | 3 + .../QueryCardCommonResult/styles.module.scss | 35 + .../QueryCardHeader/QueryCardHeader.spec.tsx | 44 + .../QueryCardHeader/QueryCardHeader.tsx | 225 + .../query-card/QueryCardHeader/index.ts | 3 + .../QueryCardHeader/styles.module.scss | 139 + .../QueryCardTooltip.spec.tsx | 12 + .../QueryCardTooltip/QueryCardTooltip.tsx | 62 + .../query-card/QueryCardTooltip/index.ts | 3 + .../QueryCardTooltip/styles.module.scss | 28 + .../ui/src/components/query-card/index.ts | 3 + .../components/query-card/styles.module.scss | 73 + .../src/components/query/Query/Query.spec.tsx | 39 + .../ui/src/components/query/Query/Query.tsx | 146 + .../ui/src/components/query/Query/index.ts | 3 + .../components/query/Query/styles.module.scss | 61 + .../components/query/QueryWrapper.spec.tsx | 48 + .../ui/src/components/query/QueryWrapper.tsx | 42 + redisinsight/ui/src/components/query/index.ts | 3 + .../TableColumnSearchTrigger.spec.tsx | 31 + .../TableColumnSearchTrigger.tsx | 106 + .../styles.module.scss | 33 + .../TableColumnSearch.spec.tsx | 26 + .../table-column-search/TableColumnSearch.tsx | 58 + .../table-column-search/styles.module.scss | 12 + .../virtual-table/VirtualTable.spec.tsx | 227 + .../components/virtual-table/VirtualTable.tsx | 439 + .../components/virtual-table/interfaces.ts | 73 + .../virtual-table/styles.module.scss | 248 + .../ui/src/constants/allRedisModules.json | 450 + redisinsight/ui/src/constants/api.ts | 56 + redisinsight/ui/src/constants/apiErrors.ts | 14 + .../ui/src/constants/apiStatusCode.ts | 6 + redisinsight/ui/src/constants/breadcrumbs.ts | 40 + redisinsight/ui/src/constants/cliOutput.tsx | 35 + redisinsight/ui/src/constants/commands.ts | 82 + .../ui/src/constants/commandsVersions.ts | 8 + redisinsight/ui/src/constants/env.ts | 5 + redisinsight/ui/src/constants/help-texts.tsx | 70 + redisinsight/ui/src/constants/index.ts | 18 + .../ui/src/constants/keyboardShortcuts.ts | 24 + redisinsight/ui/src/constants/keys.ts | 109 + .../constants/mocks/mock-enablement-area.ts | 35 + .../constants/mocks/mock-redis-commands.ts | 426 + redisinsight/ui/src/constants/monaco.ts | 10 + redisinsight/ui/src/constants/monacoRedis.ts | 34 + redisinsight/ui/src/constants/pages.ts | 23 + .../ui/src/constants/prop-types/keys.ts | 16 + .../ui/src/constants/prop-types/zset.ts | 12 + redisinsight/ui/src/constants/redisinsight.ts | 25 + redisinsight/ui/src/constants/regex.ts | 1 + redisinsight/ui/src/constants/routes.ts | 95 + redisinsight/ui/src/constants/storage.ts | 13 + redisinsight/ui/src/constants/table.ts | 11 + redisinsight/ui/src/constants/texts.tsx | 27 + redisinsight/ui/src/constants/themes.tsx | 19 + .../ui/src/constants/validationErrors.ts | 8 + .../ui/src/constants/workbenchPreselects.ts | 90 + redisinsight/ui/src/contexts/themeContext.tsx | 59 + redisinsight/ui/src/electron/AppElectron.tsx | 11 + .../ConfigElectron/ConfigElectron.tsx | 41 + .../components/ConfigElectron/index.tsx | 3 + .../ui/src/electron/components/index.ts | 3 + .../ui/src/electron/constants/index.ts | 4 + .../ui/src/electron/constants/ipcEvent.ts | 6 + .../src/electron/constants/storageElectron.ts | 10 + redisinsight/ui/src/electron/utils/index.ts | 3 + .../ui/src/electron/utils/ipcCheckUpdates.ts | 65 + .../electron/utils/ipcDeleteStoreValues.ts | 6 + redisinsight/ui/src/hoc/extractRouter.hoc.tsx | 25 + .../clients-list-example/package.json | 61 + .../packages/clients-list-example/src/App.tsx | 33 + .../src/assets/table_view_icon_dark.svg | 11 + .../src/assets/table_view_icon_light.svg | 11 + .../TableResult/TableResult.spec.tsx | 55 + .../components/TableResult/TableResult.tsx | 57 + .../src/components/TableResult/index.ts | 3 + .../src/components/index.ts | 5 + .../src/icons/arrow_down.js | 28 + .../src/icons/arrow_left.js | 28 + .../src/icons/arrow_right.js | 33 + .../clients-list-example/src/icons/check.js | 28 + .../clients-list-example/src/icons/copy.js | 29 + .../clients-list-example/src/icons/cross.js | 27 + .../clients-list-example/src/icons/empty.js | 23 + .../clients-list-example/src/index.html | 18 + .../clients-list-example/src/main.tsx | 23 + .../clients-list-example/src/response.json | 1 + .../src/styles/styles.scss | 100 + .../src/utils/cachedIcons.ts | 14 + .../clients-list-example/src/utils/index.ts | 7 + .../src/utils/parseResponse.ts | 10 + .../packages/clients-list-example/yarn.lock | 5813 ++++++ .../enablement-area/enablement-area.json | 49 + .../document-capabilities/introduction.html | 85 + .../working-with-hashes.html | 175 + .../enablement-area/images/aggregations.png | Bin 0 -> 6785 bytes .../aggregation-with-apply.txt | 20 + .../combined-search-with-and.txt | 18 + .../combined-search-with-geo-filter.txt | 18 + .../combined-search-with-or.txt | 18 + .../working-with-hashes/create-hash-index.txt | 14 + .../working-with-hashes/crud-create.txt | 8 + .../working-with-hashes/crud-delete.txt | 9 + .../working-with-hashes/crud-read.txt | 3 + .../working-with-hashes/crud-update.txt | 5 + .../working-with-hashes/exact-text-search.txt | 18 + .../field-specific-text-search.txt | 18 + .../working-with-hashes/fuzzy-text-search.txt | 18 + .../group-and-sort-by-aggregation.txt | 24 + .../working-with-hashes/index-info.txt | 4 + .../working-with-hashes/list-all-indexes.txt | 3 + .../multiple-tags-and-search.txt | 18 + .../multiple-tags-or-search.txt | 18 + .../numeric-range-query.txt | 19 + .../working-with-hashes/tag-search.txt | 18 + .../enablement-area/scripts/manual.txt | 6 + .../packages/redis-app-plugin-api/.gitignore | 37 + .../packages/redis-app-plugin-api/helpers.js | 8 + .../packages/redis-app-plugin-api/index.js | 45 + .../redis-app-plugin-api/package-lock.json | 5 + .../redis-app-plugin-api/package.json | 17 + .../ui/src/packages/redisearch/package.json | 66 + .../ui/src/packages/redisearch/src/App.tsx | 57 + .../src/assets/table_view_icon_dark.svg | 11 + .../src/assets/table_view_icon_light.svg | 11 + .../src/components/GroupBadge/GroupBadge.tsx | 23 + .../src/components/GroupBadge/index.ts | 3 + .../TableInfoResult/TableInfoResult.spec.tsx | 57 + .../TableInfoResult/TableInfoResult.tsx | 138 + .../src/components/TableInfoResult/index.ts | 3 + .../TableResult/TableResult.spec.tsx | 55 + .../components/TableResult/TableResult.tsx | 135 + .../src/components/TableResult/index.ts | 3 + .../redisearch/src/components/index.ts | 9 + .../redisearch/src/constants/constants.ts | 70 + .../redisearch/src/constants/index.ts | 1 + .../redisearch/src/icons/arrow_down.js | 28 + .../redisearch/src/icons/arrow_left.js | 28 + .../redisearch/src/icons/arrow_right.js | 33 + .../packages/redisearch/src/icons/check.js | 28 + .../src/packages/redisearch/src/icons/copy.js | 29 + .../packages/redisearch/src/icons/cross.js | 27 + .../packages/redisearch/src/icons/empty.js | 23 + .../ui/src/packages/redisearch/src/index.html | 18 + .../ui/src/packages/redisearch/src/main.tsx | 28 + .../src/packages/redisearch/src/response.json | 116 + .../packages/redisearch/src/response2.json | 69 + .../packages/redisearch/src/response3.json | 289 + .../packages/redisearch/src/responseInfo.json | 107 + .../redisearch/src/styles/styles.scss | 107 + .../redisearch/src/utils/cachedIcons.ts | 26 + .../redisearch/src/utils/formatLongName.ts | 17 + .../packages/redisearch/src/utils/index.ts | 9 + .../redisearch/src/utils/parseResponse.ts | 100 + .../redisearch/src/utils/replaceSpaces.ts | 10 + .../src/utils/tests/parseResponse.spec.ts | 100 + .../ui/src/packages/redisearch/yarn.lock | 5813 ++++++ .../ui/src/pages/browser/BrowserPage.spec.tsx | 111 + .../ui/src/pages/browser/BrowserPage.tsx | 231 + .../AddItemsActions.spec.tsx | 87 + .../add-items-actions/AddItemsActions.tsx | 94 + .../components/add-key/AddKey.spec.tsx | 35 + .../browser/components/add-key/AddKey.tsx | 172 + .../AddKeyCommonFields.spec.tsx | 79 + .../AddKeyCommonFields/AddKeyCommonFields.tsx | 72 + .../AddKeyFooter/AddKeyFooter.spec.tsx | 12 + .../add-key/AddKeyFooter/AddKeyFooter.tsx | 31 + .../add-key/AddKeyHash/AddKeyHash.spec.tsx | 75 + .../add-key/AddKeyHash/AddKeyHash.tsx | 251 + .../add-key/AddKeyList/AddKeyList.spec.tsx | 46 + .../add-key/AddKeyList/AddKeyList.tsx | 126 + .../AddKeyReJSON/AddKeyReJSON.spec.tsx | 74 + .../add-key/AddKeyReJSON/AddKeyReJSON.tsx | 143 + .../add-key/AddKeySet/AddKeySet.spec.tsx | 82 + .../add-key/AddKeySet/AddKeySet.tsx | 229 + .../AddKeyString/AddKeyString.spec.tsx | 54 + .../add-key/AddKeyString/AddKeyString.tsx | 124 + .../add-key/AddKeyZset/AddKeyZset.spec.tsx | 101 + .../add-key/AddKeyZset/AddKeyZset.tsx | 302 + .../add-key/constants/fields-config.ts | 139 + .../add-key/constants/key-type-options.ts | 34 + .../components/add-key/styles.module.scss | 42 + .../filter-key-type/FilterKeyType.spec.tsx | 88 + .../filter-key-type/FilterKeyType.tsx | 135 + .../components/filter-key-type/constants.ts | 53 + .../components/filter-key-type/index.ts | 3 + .../filter-key-type/styles.module.scss | 107 + .../hash-details/HashDetails.spec.tsx | 79 + .../components/hash-details/HashDetails.tsx | 285 + .../hash-details/styles.module.scss | 7 + .../add-hash-fields/AddHashFields.spec.tsx | 78 + .../add-hash-fields/AddHashFields.tsx | 210 + .../AddListElements.spec.tsx | 32 + .../add-list-elements/AddListElements.tsx | 143 + .../add-set-members/AddSetMembers.spec.tsx | 85 + .../add-set-members/AddSetMembers.tsx | 176 + .../add-zset-members/AddZsetMembers.spec.tsx | 101 + .../add-zset-members/AddZsetMembers.tsx | 272 + .../key-details-add-items/styles.module.scss | 10 + .../KeyDetailsHeader.spec.tsx | 110 + .../key-details-header/KeyDetailsHeader.tsx | 587 + .../key-details-header/styles.module.scss | 191 + .../RemoveListElements.spec.tsx | 78 + .../RemoveListElements.tsx | 274 + .../styles.module.scss | 38 + .../KeyDetails/KeyDetails.spec.tsx | 12 + .../key-details/KeyDetails/KeyDetails.tsx | 189 + .../key-details/KeyDetailsWrapper.spec.tsx | 171 + .../key-details/KeyDetailsWrapper.tsx | 101 + .../components/key-details/styles.module.scss | 49 + .../components/key-list/KeyList.spec.tsx | 58 + .../browser/components/key-list/KeyList.tsx | 334 + .../components/key-list/styles.module.scss | 64 + .../list-details/ListDetails.spec.tsx | 62 + .../components/list-details/ListDetails.tsx | 247 + .../list-details/styles.module.scss | 11 + .../popover-delete/PopoverDelete.spec.tsx | 51 + .../popover-delete/PopoverDelete.tsx | 100 + .../popover-delete/styles.module.scss | 23 + .../JSONArray/JSONArray.spec.tsx | 415 + .../rejson-details/JSONArray/JSONArray.tsx | 950 + .../rejson-details/JSONInterfaces.ts | 26 + .../JSONObject/JSONObject.spec.tsx | 405 + .../rejson-details/JSONObject/JSONObject.tsx | 975 + .../JSONScalar/JSONScalar.spec.tsx | 157 + .../rejson-details/JSONScalar/JSONScalar.tsx | 217 + .../JSONUtils/JSONUtils.spec.ts | 33 + .../rejson-details/JSONUtils/JSONUtils.ts | 26 + .../RejsonDetails/RejsonDetails.spec.tsx | 335 + .../RejsonDetails/RejsonDetails.tsx | 763 + .../RejsonDetailsWrapper.spec.tsx | 9 + .../rejson-details/RejsonDetailsWrapper.tsx | 96 + .../components/rejson-details/constants.ts | 4 + .../rejson-details/styles.module.scss | 250 + .../components/rejson-details/styles.scss | 16 + .../search-key-list/SearchKeyList.spec.tsx | 45 + .../search-key-list/SearchKeyList.tsx | 47 + .../components/search-key-list/index.ts | 3 + .../search-key-list/styles.module.scss | 33 + .../set-details/SetDetails.spec.tsx | 63 + .../components/set-details/SetDetails.tsx | 181 + .../components/set-details/styles.module.scss | 7 + .../string-details/StringDetails.spec.tsx | 101 + .../string-details/StringDetails.tsx | 130 + .../string-details/styles.module.scss | 42 + .../UnsupportedTypeDetails.spec.tsx | 10 + .../UnsupportedTypeDetails.tsx | 32 + .../styles.module.scss | 25 + .../zset-details/ZSetDetails.spec.tsx | 75 + .../components/zset-details/ZSetDetails.tsx | 317 + .../zset-details/styles.module.scss | 7 + redisinsight/ui/src/pages/browser/index.ts | 5 + .../ui/src/pages/browser/styles.module.scss | 88 + .../ui/src/pages/home/HomePage.spec.tsx | 10 + redisinsight/ui/src/pages/home/HomePage.tsx | 283 + .../AddDatabasesContainer.spec.tsx | 20 + .../AddDatabases/AddDatabasesContainer.tsx | 226 + .../InstanceConnections.spec.tsx | 21 + .../InstanceConnections.tsx | 151 + .../AddDatabases/styles.module.scss | 67 + .../AddInstanceControls.tsx | 192 + .../AddInstanceControls/styles.module.scss | 174 + .../InstanceForm/InstanceForm.spec.tsx | 455 + .../InstanceForm/InstanceForm.tsx | 1239 ++ .../InstanceForm/styles.module.scss | 131 + .../InstanceFormWrapper.spec.tsx | 146 + .../AddInstanceForm/InstanceFormWrapper.tsx | 445 + .../CloudConnectionForm.spec.tsx | 14 + .../CloudConnectionForm.tsx | 231 + .../CloudConnectionFormWrapper.spec.tsx | 16 + .../CloudConnectionFormWrapper.tsx | 69 + .../CloudConnection/styles.module.scss | 20 + .../ClusterConnectionForm.spec.tsx | 14 + .../ClusterConnectionForm.tsx | 393 + .../ClusterConnectionFormWrapper.spec.tsx | 71 + .../ClusterConnectionFormWrapper.tsx | 135 + .../ClusterConnection/styles.module.scss | 20 + .../components/ClusterConnection/types.ts | 6 + .../DatabaseAlias/DatabaseAlias.tsx | 151 + .../home/components/DatabaseAlias/index.ts | 3 + .../DatabaseAlias/styles.module.scss | 40 + .../DatabasesList/DatabasesList.spec.tsx | 38 + .../DatabasesList/DatabasesList.tsx | 215 + .../DatabasesListWrapper.spec.tsx | 80 + .../DatabasesListWrapper.tsx | 336 + .../DatabasesListComponent/styles.module.scss | 95 + .../HelpLinksMenu/HelpLinksMenu.tsx | 73 + .../home/components/HelpLinksMenu/index.ts | 3 + .../HelpLinksMenu/styles.module.scss | 40 + .../WelcomeComponent.spec.tsx | 14 + .../WelcomeComponent/WelcomeComponent.tsx | 81 + .../WelcomeComponent/styles.module.scss | 58 + .../ui/src/pages/home/constants/help-links.ts | 31 + redisinsight/ui/src/pages/home/index.ts | 5 + .../ui/src/pages/home/styles.module.scss | 3 + redisinsight/ui/src/pages/home/styles.scss | 492 + redisinsight/ui/src/pages/index.ts | 9 + .../src/pages/instance/InstancePage.spec.tsx | 65 + .../ui/src/pages/instance/InstancePage.tsx | 118 + .../instance/InstancePageRouter.spec.tsx | 17 + .../src/pages/instance/InstancePageRouter.tsx | 17 + redisinsight/ui/src/pages/instance/index.ts | 6 + .../ui/src/pages/instance/styles.module.scss | 15 + .../pages/redisCloud/RedisCloudPage.spec.tsx | 26 + .../src/pages/redisCloud/RedisCloudPage.tsx | 17 + redisinsight/ui/src/pages/redisCloud/index.ts | 5 + .../RedisCloudDatabases.spec.tsx | 30 + .../RedisCloudDatabases.tsx | 251 + .../RedisCloudDatabases/index.ts | 3 + .../RedisCloudDatabasesPage.spec.tsx | 59 + .../RedisCloudDatabasesPage.tsx | 225 + .../ui/src/pages/redisCloudDatabases/index.ts | 5 + .../redisCloudDatabases/styles.module.scss | 86 + .../RedisCloudDatabasesResult.spec.tsx | 30 + .../RedisCloudDatabasesResult.tsx | 168 + .../RedisCloudDatabasesResultPage.spec.tsx | 53 + .../RedisCloudDatabasesResultPage.tsx | 235 + .../pages/redisCloudDatabasesResult/index.ts | 5 + .../styles.module.scss | 85 + .../RedisCloudSubscriptions.spec.tsx | 46 + .../RedisCloudSubscriptions.tsx | 301 + .../RedisCloudSubscriptionsPage.spec.tsx | 10 + .../RedisCloudSubscriptionsPage.tsx | 216 + .../pages/redisCloudSubscriptions/index.ts | 5 + .../styles.module.scss | 192 + .../redisCluster/RedisClusterDatabases.tsx | 228 + .../RedisClusterDatabasesPage.spec.tsx | 10 + .../RedisClusterDatabasesPage.tsx | 209 + .../RedisClusterDatabasesResult.spec.tsx | 32 + .../RedisClusterDatabasesResult.tsx | 173 + .../ui/src/pages/redisCluster/index.ts | 5 + .../src/pages/redisCluster/styles.module.scss | 136 + .../src/pages/sentinel/SentinelPage.spec.tsx | 26 + .../ui/src/pages/sentinel/SentinelPage.tsx | 17 + redisinsight/ui/src/pages/sentinel/index.ts | 5 + .../SentinelDatabasesPage.spec.tsx | 81 + .../SentinelDatabasesPage.tsx | 272 + .../SentinelDatabases.spec.tsx | 111 + .../SentinelDatabases/SentinelDatabases.tsx | 269 + .../sentinelDatabases/components/index.ts | 3 + .../ui/src/pages/sentinelDatabases/index.ts | 5 + .../sentinelDatabases/styles.module.scss | 110 + .../SentinelDatabasesResultPage.spec.tsx | 27 + .../SentinelDatabasesResultPage.tsx | 378 + .../SentinelDatabasesResult.spec.tsx | 96 + .../SentinelDatabasesResult.tsx | 174 + .../styles.module.scss | 85 + .../components/index.ts | 3 + .../pages/sentinelDatabasesResult/index.ts | 5 + .../src/pages/settings/SettingsPage.spec.tsx | 34 + .../ui/src/pages/settings/SettingsPage.tsx | 161 + redisinsight/ui/src/pages/settings/index.ts | 5 + .../ui/src/pages/settings/styles.module.scss | 88 + .../pages/workbench/WorkbenchPage.spec.tsx | 35 + .../ui/src/pages/workbench/WorkbenchPage.tsx | 27 + .../EnablementArea/EnablementArea.spec.tsx | 123 + .../EnablementArea/EnablementArea.tsx | 103 + .../components/Carousel/Carousel.spec.tsx | 40 + .../components/Carousel/Carousel.tsx | 45 + .../components/Carousel/index.ts | 3 + .../components/Carousel/styles.module.scss | 9 + .../components/CodeButton/CodeButton.spec.tsx | 26 + .../components/CodeButton/CodeButton.tsx | 27 + .../components/CodeButton/index.ts | 3 + .../components/CodeButton/styles.module.scss | 14 + .../EmptyPrompt/EmptyPrompt.spec.tsx | 9 + .../components/EmptyPrompt/EmptyPrompt.tsx | 36 + .../components/EmptyPrompt/index.ts | 3 + .../components/EmptyPrompt/styles.module.scss | 20 + .../components/Group/Group.spec.tsx | 25 + .../EnablementArea/components/Group/Group.tsx | 41 + .../EnablementArea/components/Group/index.ts | 3 + .../components/Group/styles.scss | 40 + .../components/Image/Image.spec.tsx | 25 + .../EnablementArea/components/Image/Image.tsx | 15 + .../EnablementArea/components/Image/index.ts | 3 + .../InternalLink/InternalLink.spec.tsx | 45 + .../components/InternalLink/InternalLink.tsx | 50 + .../components/InternalLink/index.ts | 3 + .../InternalLink/styles.module.scss | 33 + .../InternalPage/InternalPage.spec.tsx | 49 + .../components/InternalPage/InternalPage.tsx | 81 + .../components/InternalPage/index.ts | 3 + .../InternalPage/styles.module.scss | 38 + .../components/InternalPage/styles.scss | 78 + .../LazyCodeButton/LazyCodeButton.spec.tsx | 62 + .../LazyCodeButton/LazyCodeButton.tsx | 44 + .../components/LazyCodeButton/index.ts | 3 + .../LazyInternalPage/LazyInternalPage.tsx | 53 + .../components/LazyInternalPage/index.ts | 3 + .../components/PlainText/PlainText.spec.tsx | 17 + .../components/PlainText/PlainText.tsx | 17 + .../components/PlainText/index.ts | 3 + .../EnablementArea/components/index.ts | 23 + .../enablament-area/EnablementArea/index.ts | 3 + .../EnablementArea/styles.module.scss | 45 + .../EnablementArea/styles.scss | 6 + .../EnablementAreaWrapper.spec.tsx | 37 + .../enablament-area/EnablementAreaWrapper.tsx | 38 + .../components/enablament-area/index.ts | 3 + .../ModuleNotLoaded.spec.tsx | 58 + .../module-not-loaded/ModuleNotLoaded.tsx | 117 + .../components/module-not-loaded/index.ts | 5 + .../module-not-loaded/styles.module.scss | 103 + .../wb-results/WBResults/WBResults.spec.tsx | 65 + .../wb-results/WBResults/WBResults.tsx | 37 + .../components/wb-results/WBResults/index.ts | 3 + .../wb-results/WBResults/styles.module.scss | 15 + .../wb-results/WBResultsWrapper.spec.tsx | 43 + .../wb-results/WBResultsWrapper.tsx | 17 + .../workbench/components/wb-results/index.ts | 3 + .../components/wb-view/WBView/WBView.spec.tsx | 71 + .../components/wb-view/WBView/WBView.tsx | 153 + .../components/wb-view/WBView/index.ts | 3 + .../wb-view/WBView/styles.module.scss | 59 + .../components/wb-view/WBViewWrapper.spec.tsx | 148 + .../components/wb-view/WBViewWrapper.tsx | 277 + .../workbench/components/wb-view/index.ts | 3 + .../ui/src/pages/workbench/constants.ts | 50 + .../contexts/enablementAreaContext.tsx | 18 + redisinsight/ui/src/pages/workbench/index.ts | 3 + .../ui/src/pages/workbench/interfaces.ts | 14 + redisinsight/ui/src/plugins/pluginEvents.ts | 55 + redisinsight/ui/src/plugins/pluginImport.ts | 108 + redisinsight/ui/src/resourses/en-EN.ts | 7 + redisinsight/ui/src/services/PluginAPI.ts | 22 + redisinsight/ui/src/services/apiService.ts | 16 + redisinsight/ui/src/services/hooks.ts | 46 + redisinsight/ui/src/services/index.ts | 9 + redisinsight/ui/src/services/queryHistory.ts | 125 + .../ui/src/services/resourcesService.ts | 13 + redisinsight/ui/src/services/routing.ts | 40 + redisinsight/ui/src/services/storage.ts | 51 + .../src/services/tests/queryHistory.spec.ts | 139 + redisinsight/ui/src/services/theme.ts | 37 + redisinsight/ui/src/setup-tests.ts | 1 + redisinsight/ui/src/slices/app/context.ts | 88 + redisinsight/ui/src/slices/app/info.ts | 95 + .../ui/src/slices/app/notifications.ts | 83 + redisinsight/ui/src/slices/app/plugins.ts | 128 + .../ui/src/slices/app/redis-commands.ts | 68 + redisinsight/ui/src/slices/caCerts.ts | 68 + redisinsight/ui/src/slices/cli/cli-output.ts | 192 + .../ui/src/slices/cli/cli-settings.ts | 270 + redisinsight/ui/src/slices/clientCerts.ts | 69 + redisinsight/ui/src/slices/cloud.ts | 297 + redisinsight/ui/src/slices/cluster.ts | 149 + redisinsight/ui/src/slices/hash.ts | 402 + redisinsight/ui/src/slices/instances.ts | 423 + redisinsight/ui/src/slices/interfaces/app.ts | 95 + redisinsight/ui/src/slices/interfaces/cli.ts | 33 + redisinsight/ui/src/slices/interfaces/hash.ts | 11 + .../ui/src/slices/interfaces/index.ts | 4 + .../ui/src/slices/interfaces/instances.ts | 371 + redisinsight/ui/src/slices/interfaces/list.ts | 11 + redisinsight/ui/src/slices/interfaces/user.ts | 10 + .../ui/src/slices/interfaces/workbench.ts | 33 + redisinsight/ui/src/slices/interfaces/zset.ts | 16 + redisinsight/ui/src/slices/keys.ts | 697 + redisinsight/ui/src/slices/list.ts | 435 + redisinsight/ui/src/slices/rejson.ts | 316 + redisinsight/ui/src/slices/sentinel.ts | 201 + redisinsight/ui/src/slices/set.ts | 329 + redisinsight/ui/src/slices/store.ts | 79 + redisinsight/ui/src/slices/string.ts | 141 + .../ui/src/slices/tests/app/context.spec.ts | 256 + .../ui/src/slices/tests/app/info.spec.ts | 239 + .../slices/tests/app/notifications.spec.ts | 234 + .../slices/tests/app/redis-commands.spec.ts | 144 + .../ui/src/slices/tests/caCerts.spec.ts | 181 + .../src/slices/tests/cli/cli-output.spec.ts | 364 + .../src/slices/tests/cli/cli-settings.spec.ts | 527 + .../ui/src/slices/tests/clientCerts.spec.ts | 144 + .../ui/src/slices/tests/cloud.spec.ts | 813 + .../ui/src/slices/tests/cluster.spec.ts | 491 + redisinsight/ui/src/slices/tests/hash.spec.ts | 746 + .../ui/src/slices/tests/instances.spec.ts | 829 + redisinsight/ui/src/slices/tests/keys.spec.ts | 1242 ++ redisinsight/ui/src/slices/tests/list.spec.ts | 896 + .../ui/src/slices/tests/rejson.spec.ts | 611 + .../ui/src/slices/tests/sentinel.spec.ts | 462 + redisinsight/ui/src/slices/tests/set.spec.ts | 704 + .../ui/src/slices/tests/string.spec.ts | 347 + .../ui/src/slices/tests/user/settings.spec.ts | 444 + .../workbench/wb-enablement-area.spec.ts | 153 + .../slices/tests/workbench/wb-results.spec.ts | 272 + .../tests/workbench/wb-settings.spec.ts | 209 + redisinsight/ui/src/slices/tests/zset.spec.ts | 1236 ++ .../ui/src/slices/user/user-settings.ts | 174 + .../slices/workbench/wb-enablement-area.ts | 76 + .../ui/src/slices/workbench/wb-results.ts | 189 + .../ui/src/slices/workbench/wb-settings.ts | 116 + redisinsight/ui/src/slices/zset.ts | 521 + redisinsight/ui/src/styles/base/_base.scss | 63 + .../ui/src/styles/base/_flex_groups.scss | 5 + redisinsight/ui/src/styles/base/_fonts.scss | 61 + .../ui/src/styles/base/_functions.scss | 3 + .../src/styles/base/_functions_electron.scss | 4 + redisinsight/ui/src/styles/base/_helpers.scss | 69 + redisinsight/ui/src/styles/base/_inputs.scss | 46 + redisinsight/ui/src/styles/base/_links.scss | 11 + redisinsight/ui/src/styles/base/_monaco.scss | 18 + .../ui/src/styles/base/_overrides.scss | 49 + .../src/styles/base/_react_virtualized.scss | 61 + redisinsight/ui/src/styles/base/_selects.scss | 32 + .../ui/src/styles/base/_typography.scss | 98 + .../ui/src/styles/components/_accordion.scss | 67 + .../ui/src/styles/components/_badge.scss | 3 + .../ui/src/styles/components/_buttons.scss | 97 + .../ui/src/styles/components/_components.scss | 14 + .../ui/src/styles/components/_database.scss | 9 + .../ui/src/styles/components/_forms.scss | 141 + .../ui/src/styles/components/_popover.scss | 17 + .../ui/src/styles/components/_radio.scss | 13 + .../components/_resizable_container.scss | 7 + .../ui/src/styles/components/_switch.scss | 16 + .../ui/src/styles/components/_table.scss | 150 + .../ui/src/styles/components/_textarea.scss | 9 + .../ui/src/styles/components/_toasts.scss | 71 + .../ui/src/styles/components/_tool_tip.scss | 28 + redisinsight/ui/src/styles/main.scss | 13 + redisinsight/ui/src/styles/main_plugin.scss | 54 + .../themes/dark_theme/_dark_theme.lazy.scss | 141 + .../themes/dark_theme/_theme_color.scss | 102 + .../themes/light_theme/_light_theme.lazy.scss | 143 + .../themes/light_theme/_theme_color.scss | 100 + redisinsight/ui/src/telemetry/analytics.d.ts | 6 + redisinsight/ui/src/telemetry/events.ts | 51 + redisinsight/ui/src/telemetry/index.ts | 7 + redisinsight/ui/src/telemetry/interfaces.ts | 30 + .../ui/src/telemetry/loadSegmentAnalytics.ts | 91 + redisinsight/ui/src/telemetry/pageViews.ts | 6 + redisinsight/ui/src/telemetry/segment.ts | 103 + .../ui/src/telemetry/telemetryUtils.ts | 82 + .../tests/loadSegmentAnalytics.spec.ts | 23 + redisinsight/ui/src/utils/apiResponse.ts | 34 + redisinsight/ui/src/utils/cli.tsx | 118 + redisinsight/ui/src/utils/commands.ts | 74 + redisinsight/ui/src/utils/common.ts | 9 + redisinsight/ui/src/utils/compareConsents.ts | 25 + redisinsight/ui/src/utils/compareVersions.ts | 37 + redisinsight/ui/src/utils/errors.ts | 3 + redisinsight/ui/src/utils/formatBytes.ts | 18 + redisinsight/ui/src/utils/getUrlInstance.ts | 3 + redisinsight/ui/src/utils/handleBrowsers.ts | 1 + .../ui/src/utils/handlePasteHostName.ts | 22 + redisinsight/ui/src/utils/handlePlatforms.ts | 1 + redisinsight/ui/src/utils/index.ts | 43 + redisinsight/ui/src/utils/instanceModules.ts | 9 + redisinsight/ui/src/utils/instanceOptions.ts | 25 + redisinsight/ui/src/utils/longNames.ts | 28 + redisinsight/ui/src/utils/monaco.ts | 30 + .../utils/monacoRedisComplitionProvider.ts | 86 + .../utils/monacoRedisMonarchTokensProvider.ts | 89 + redisinsight/ui/src/utils/numbers.ts | 5 + redisinsight/ui/src/utils/parseResponse.ts | 85 + redisinsight/ui/src/utils/plugins.ts | 12 + redisinsight/ui/src/utils/removeEmpty.ts | 8 + redisinsight/ui/src/utils/replaceSpaces.ts | 6 + .../ui/src/utils/routerWithSubRoutes.tsx | 38 + redisinsight/ui/src/utils/setFavicon.ts | 12 + redisinsight/ui/src/utils/setPageTitle.ts | 5 + redisinsight/ui/src/utils/statuses.ts | 17 + redisinsight/ui/src/utils/test-utils.tsx | 161 + .../ui/src/utils/tests/apiResponse.spec.ts | 13 + .../ui/src/utils/tests/commands.spec.ts | 204 + .../src/utils/tests/compareConsents.spec.ts | 63 + .../src/utils/tests/compareVersions.spec.ts | 49 + .../ui/src/utils/tests/formatTypes.spec.ts | 46 + .../ui/src/utils/tests/handlePlatform.spec.ts | 24 + .../src/utils/tests/instanceOptions.spec.ts | 56 + .../ui/src/utils/tests/longNames.spec.ts | 27 + .../ui/src/utils/tests/monaco.spec.ts | 70 + .../ui/src/utils/tests/parseResponse.spec.ts | 217 + .../ui/src/utils/tests/removeEmpty.spec.ts | 9 + .../ui/src/utils/tests/replaceSpaces.spec.ts | 23 + .../utils/tests/routerWithSubRoutes.spec.tsx | 18 + .../ui/src/utils/tests/truncateNumber.spec.ts | 77 + .../ui/src/utils/tests/truncateTTL.spec.ts | 135 + .../ui/src/utils/tests/validations.spec.ts | 165 + redisinsight/ui/src/utils/trancateNumber.ts | 27 + redisinsight/ui/src/utils/truncateTTL.ts | 77 + redisinsight/ui/src/utils/types.ts | 9 + redisinsight/ui/src/utils/validations.ts | 74 + redisinsight/ui/src/utils/workbench.ts | 39 + redisinsight/yarn.lock | 1134 ++ resources/entitlements.mac.plist | 18 + resources/icon-tray-colored.png | Bin 0 -> 3946 bytes resources/icon-tray-white.png | Bin 0 -> 2231 bytes resources/icon.icns | Bin 0 -> 100352 bytes resources/icon.ico | Bin 0 -> 119246 bytes resources/icon.png | Bin 0 -> 41980 bytes resources/icon.svg | 23 + resources/icon_default.icns | Bin 0 -> 483069 bytes resources/icon_default.ico | Bin 0 -> 114866 bytes resources/icon_default.png | Bin 0 -> 5109 bytes resources/icons/128x128.png | Bin 0 -> 12593 bytes resources/icons/16x16.png | Bin 0 -> 933 bytes resources/icons/24x24.png | Bin 0 -> 1621 bytes resources/icons/256x256.png | Bin 0 -> 30872 bytes resources/icons/32x32.png | Bin 0 -> 2386 bytes resources/icons/48x48.png | Bin 0 -> 4076 bytes resources/icons/512x215.png | Bin 0 -> 41980 bytes resources/icons/64x64.png | Bin 0 -> 5868 bytes resources/icons/96x96.png | Bin 0 -> 9319 bytes resources/icons_default/1024x1024.png | Bin 0 -> 102236 bytes resources/icons_default/128x128.png | Bin 0 -> 9021 bytes resources/icons_default/16x16.png | Bin 0 -> 3264 bytes resources/icons_default/24x24.png | Bin 0 -> 3996 bytes resources/icons_default/256x256.png | Bin 0 -> 17151 bytes resources/icons_default/32x32.png | Bin 0 -> 3538 bytes resources/icons_default/48x48.png | Bin 0 -> 4692 bytes resources/icons_default/512x512.png | Bin 0 -> 37645 bytes resources/icons_default/64x64.png | Bin 0 -> 5778 bytes resources/icons_default/96x96.png | Bin 0 -> 7910 bytes resources/resources.d.ts | 14 + scripts/.eslintrc | 9 + scripts/BabelRegister.js | 6 + scripts/DeleteDistWeb.js | 6 + scripts/DeleteSourceMaps.js | 7 + scripts/build-statics.cmd | 25 + scripts/build-statics.sh | 23 + tests/e2e/.dockerignore | 2 + tests/e2e/.env | 2 + tests/e2e/.eslintignore | 4 + tests/e2e/.eslintrc | 174 + tests/e2e/README.md | 42 + tests/e2e/docker-compose.yml | 64 + tests/e2e/docker.docker-compose.yml | 7 + tests/e2e/e2e.Dockerfile | 17 + tests/e2e/helpers/common.ts | 64 + tests/e2e/helpers/conf.ts | 54 + tests/e2e/helpers/constants.ts | 23 + tests/e2e/helpers/database.ts | 96 + tests/e2e/helpers/helpers.ts | 33 + tests/e2e/package.json | 37 + .../pageObjects/add-redis-database-page.ts | 189 + ...uto-discover-redis-enterprise-databases.ts | 33 + tests/e2e/pageObjects/browser-page.ts | 626 + tests/e2e/pageObjects/cli-page.ts | 92 + .../e2e/pageObjects/database-overview-page.ts | 26 + tests/e2e/pageObjects/index.ts | 21 + .../pageObjects/my-redis-databases-page.ts | 89 + .../adding-master-groups-result-page.ts | 28 + .../discovered-sentinel-master-groups-page.ts | 37 + tests/e2e/pageObjects/settings-page.ts | 53 + tests/e2e/pageObjects/user-agreement-page.ts | 38 + tests/e2e/pageObjects/workbench-page.ts | 115 + tests/e2e/rte/oss-sentinel/Dockerfile | 14 + tests/e2e/rte/oss-sentinel/entrypoint.sh | 10 + tests/e2e/rte/oss-sentinel/sentinel.conf | 15 + tests/e2e/rte/redis-enterprise/Dockerfile | 20 + tests/e2e/rte/redis-enterprise/cert.pem | 32 + tests/e2e/rte/redis-enterprise/create_dbs.py | 215 + .../redis-enterprise/run_re_and_create_db.sh | 39 + .../critical-path/browser/context.e2e.ts | 163 + .../browser/database-overview.e2e.ts | 129 + .../critical-path/browser/filtering.e2e.ts | 80 + .../critical-path/browser/hash-field.e2e.ts | 66 + .../critical-path/browser/json-key.e2e.ts | 57 + .../critical-path/browser/large-data.e2e.ts | 68 + .../critical-path/browser/list-key.e2e.ts | 76 + .../critical-path/browser/scan-keys.e2e.ts | 78 + .../critical-path/browser/set-key.e2e.ts | 64 + .../critical-path/browser/zset-key.e2e.ts | 68 + .../cli/cli-command-helper.e2e.ts | 114 + .../critical-path/cli/cli-critical.e2e.ts | 88 + .../database/connecting-to-the-db.e2e.ts | 33 + .../database/verify-agreements.e2e.ts | 44 + .../critical-path/settings/settings.e2e.ts | 54 + .../workbench/autocomplete.e2e.ts | 92 + .../workbench/command-results.e2e.ts | 66 + .../critical-path/workbench/context.e2e.ts | 42 + .../workbench/default-scripts-area.e2e.ts | 113 + .../workbench/index-schema.e2e.ts | 73 + .../workbench/json-workbench.e2e.ts | 55 + .../redisearch-module-not-available.e2e.ts | 38 + .../workbench/scripting-area.e2e.ts | 153 + .../tests/regression/browser/context.e2e.ts | 76 + .../browser/database-overview.e2e.ts | 46 + .../browser/filtering-iteratively.e2e.ts | 74 + .../tests/regression/browser/filtering.e2e.ts | 120 + .../regression/browser/last-refresh.e2e.ts | 86 + .../regression/workbench/autocomplete.e2e.ts | 51 + .../tests/regression/workbench/context.e2e.ts | 71 + .../workbench/default-scripts-area.e2e.ts | 65 + .../redisearch-module-not-available.e2e.ts | 43 + .../workbench/scripting-area.e2e.ts | 69 + tests/e2e/tests/smoke/browser/add-keys.e2e.ts | 110 + .../tests/smoke/browser/edit-key-name.e2e.ts | 108 + .../tests/smoke/browser/edit-key-value.e2e.ts | 47 + .../e2e/tests/smoke/browser/filtering.e2e.ts | 87 + .../e2e/tests/smoke/browser/hash-field.e2e.ts | 58 + tests/e2e/tests/smoke/browser/json-key.e2e.ts | 63 + tests/e2e/tests/smoke/browser/list-key.e2e.ts | 71 + .../browser/list-of-keys-verifications.e2e.ts | 101 + tests/e2e/tests/smoke/browser/set-key.e2e.ts | 52 + .../smoke/browser/set-ttl-for-key.e2e.ts | 50 + .../smoke/browser/verify-key-details.e2e.ts | 139 + .../smoke/browser/verify-keys-refresh.e2e.ts | 53 + tests/e2e/tests/smoke/browser/zset-key.e2e.ts | 55 + .../tests/smoke/cli/cli-command-helper.e2e.ts | 114 + tests/e2e/tests/smoke/cli/cli.e2e.ts | 101 + .../database/add-db-from-welcome-page.e2e.ts | 27 + .../smoke/database/add-sentinel-db.e2e.ts | 21 + .../smoke/database/add-standalone-db.e2e.ts | 34 + .../database/connecting-to-the-db.e2e.ts | 52 + .../tests/smoke/database/delete-the-db.e2e.ts | 21 + tests/e2e/tests/smoke/database/edit-db.e2e.ts | 42 + .../smoke/workbench/json-workbench.e2e.ts | 54 + .../smoke/workbench/scripting-area.e2e.ts | 76 + tests/e2e/wait-for-it.sh | 182 + tests/e2e/yarn.lock | 4964 ++++++ tsconfig.json | 57 + yarn.lock | 14864 ++++++++++++++++ 1426 files changed, 174974 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .circleci/deps-audit-report.js create mode 100644 .circleci/e2e-results.js create mode 100644 .circleci/itest-results.js create mode 100644 .circleci/lint-report.js create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .gitignore create mode 100644 .yarnrc create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 api-docker-entry.sh create mode 100644 api.Dockerfile create mode 100644 babel.config.js create mode 100644 configs/.eslintrc create mode 100644 configs/paths.js create mode 100644 configs/webpack.config.base.js create mode 100644 configs/webpack.config.eslint.js create mode 100644 configs/webpack.config.main.prod.babel.js create mode 100644 configs/webpack.config.main.stage.babel.js create mode 100644 configs/webpack.config.renderer.dev.babel.js create mode 100644 configs/webpack.config.renderer.dev.dll.babel.js create mode 100644 configs/webpack.config.renderer.prod.babel.js create mode 100644 configs/webpack.config.renderer.stage.babel.js create mode 100644 configs/webpack.config.web.common.babel.js create mode 100644 configs/webpack.config.web.dev.babel.js create mode 100644 configs/webpack.config.web.prod.babel.js create mode 100644 docker-entry.sh create mode 100644 electron-builder.json create mode 100644 package.json create mode 100644 redisinsight/__mocks__/fileMock.js create mode 100644 redisinsight/__mocks__/monacoMock.js create mode 100644 redisinsight/about-panel.ts create mode 100644 redisinsight/api/.dockerignore create mode 100644 redisinsight/api/.eslintignore create mode 100644 redisinsight/api/.eslintrc.js create mode 100644 redisinsight/api/.gitignore create mode 100644 redisinsight/api/.prettierignore create mode 100644 redisinsight/api/.prettierrc create mode 100644 redisinsight/api/.yarnclean.prod create mode 100644 redisinsight/api/README.md create mode 100644 redisinsight/api/config/default.ts create mode 100644 redisinsight/api/config/development.ts create mode 100644 redisinsight/api/config/logger.ts create mode 100644 redisinsight/api/config/ormconfig.ts create mode 100644 redisinsight/api/config/production.ts create mode 100644 redisinsight/api/config/staging.ts create mode 100644 redisinsight/api/config/swagger.ts create mode 100644 redisinsight/api/config/test.ts create mode 100644 redisinsight/api/migration/1614164490968-initial-migration.ts create mode 100644 redisinsight/api/migration/1615480887019-connection-type.ts create mode 100644 redisinsight/api/migration/1615990079125-database-name-from-provider.ts create mode 100644 redisinsight/api/migration/1615992183565-remove-database-type.ts create mode 100644 redisinsight/api/migration/1616520395940-oss-sentinel.ts create mode 100644 redisinsight/api/migration/1625771635418-agreements.ts create mode 100644 redisinsight/api/migration/1626086601057-server-info.ts create mode 100644 redisinsight/api/migration/1626904405170-database-hosting-provider.ts create mode 100644 redisinsight/api/migration/1627556171227-settings.ts create mode 100644 redisinsight/api/migration/1629729923740-database-modules.ts create mode 100644 redisinsight/api/migration/1634219846022-database-db-index.ts create mode 100644 redisinsight/api/migration/1634557312500-encryption.ts create mode 100644 redisinsight/api/migration/index.ts create mode 100644 redisinsight/api/nest-cli.json create mode 100644 redisinsight/api/package.json create mode 100644 redisinsight/api/package.tmp.json create mode 100644 redisinsight/api/src/__mocks__/analytics.ts create mode 100644 redisinsight/api/src/__mocks__/app-settings.ts create mode 100644 redisinsight/api/src/__mocks__/autodiscovery-tools.ts create mode 100644 redisinsight/api/src/__mocks__/certificates.ts create mode 100644 redisinsight/api/src/__mocks__/commands.ts create mode 100644 redisinsight/api/src/__mocks__/common.ts create mode 100644 redisinsight/api/src/__mocks__/encryption.ts create mode 100644 redisinsight/api/src/__mocks__/errors.ts create mode 100644 redisinsight/api/src/__mocks__/index.ts create mode 100644 redisinsight/api/src/__mocks__/redis-databases.ts create mode 100644 redisinsight/api/src/__mocks__/redis-info.ts create mode 100644 redisinsight/api/src/app.module.ts create mode 100644 redisinsight/api/src/app.routes.ts create mode 100644 redisinsight/api/src/constants/agreements-spec.json create mode 100644 redisinsight/api/src/constants/app-events.ts create mode 100644 redisinsight/api/src/constants/commands/main.json create mode 100644 redisinsight/api/src/constants/commands/redijson.json create mode 100644 redisinsight/api/src/constants/commands/redisai.json create mode 100644 redisinsight/api/src/constants/commands/redisearch.json create mode 100644 redisinsight/api/src/constants/commands/redisgraph.json create mode 100644 redisinsight/api/src/constants/commands/redistimeseries.json create mode 100644 redisinsight/api/src/constants/error-messages.ts create mode 100644 redisinsight/api/src/constants/exceptions.ts create mode 100644 redisinsight/api/src/constants/index.ts create mode 100644 redisinsight/api/src/constants/redis-commands.ts create mode 100644 redisinsight/api/src/constants/redis-connection.ts create mode 100644 redisinsight/api/src/constants/redis-error-codes.ts create mode 100644 redisinsight/api/src/constants/redis-keys.ts create mode 100644 redisinsight/api/src/constants/redis-modules.ts create mode 100644 redisinsight/api/src/constants/regex.ts create mode 100644 redisinsight/api/src/constants/sort.ts create mode 100644 redisinsight/api/src/constants/telemetry-events.ts create mode 100644 redisinsight/api/src/controllers/server-info.controller.ts create mode 100644 redisinsight/api/src/controllers/settings.controller.ts create mode 100644 redisinsight/api/src/decorators/api-endpoint.decorator.ts create mode 100644 redisinsight/api/src/decorators/api-redis-instance-operation.decorator.ts create mode 100644 redisinsight/api/src/decorators/api-redis-params.decorator.ts create mode 100644 redisinsight/api/src/dto/dto-transformer.spec.ts create mode 100644 redisinsight/api/src/dto/dto-transformer.ts create mode 100644 redisinsight/api/src/dto/server.dto.ts create mode 100644 redisinsight/api/src/dto/settings.dto.ts create mode 100644 redisinsight/api/src/main.ts create mode 100644 redisinsight/api/src/middleware/redis-connection.middleware.ts create mode 100644 redisinsight/api/src/models/agreements.interface.ts create mode 100644 redisinsight/api/src/models/index.ts create mode 100644 redisinsight/api/src/models/redis-client.ts create mode 100644 redisinsight/api/src/models/redis-cluster.ts create mode 100644 redisinsight/api/src/models/redis-consumer.interface.ts create mode 100644 redisinsight/api/src/modules/browser/browser.module.ts create mode 100644 redisinsight/api/src/modules/browser/constants/browser-tool-commands.ts create mode 100644 redisinsight/api/src/modules/browser/controllers/hash/hash.controller.ts create mode 100644 redisinsight/api/src/modules/browser/controllers/keys/keys.controller.ts create mode 100644 redisinsight/api/src/modules/browser/controllers/list/list.controller.ts create mode 100644 redisinsight/api/src/modules/browser/controllers/rejson-rl/rejson-rl.controller.ts create mode 100644 redisinsight/api/src/modules/browser/controllers/set/set.controller.ts create mode 100644 redisinsight/api/src/modules/browser/controllers/string/string.controller.ts create mode 100644 redisinsight/api/src/modules/browser/controllers/z-set/z-set.controller.ts create mode 100644 redisinsight/api/src/modules/browser/dto/hash.dto.ts create mode 100644 redisinsight/api/src/modules/browser/dto/index.ts create mode 100644 redisinsight/api/src/modules/browser/dto/keys.dto.ts create mode 100644 redisinsight/api/src/modules/browser/dto/list.dto.ts create mode 100644 redisinsight/api/src/modules/browser/dto/rejson-rl.dto.ts create mode 100644 redisinsight/api/src/modules/browser/dto/set.dto.ts create mode 100644 redisinsight/api/src/modules/browser/dto/string.dto.ts create mode 100644 redisinsight/api/src/modules/browser/dto/z-set.dto.ts create mode 100644 redisinsight/api/src/modules/browser/services/browser-analytics/browser-analytics.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/browser-analytics/browser-analytics.service.ts create mode 100644 redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/browser-tool-cluster/browser-tool-cluster.service.ts create mode 100644 redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/browser-tool/browser-tool.service.ts create mode 100644 redisinsight/api/src/modules/browser/services/hash-business/hash-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/hash-business/hash-business.service.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.interface.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/key-info-manager.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/graph-type-info/graph-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/graph-type-info/graph-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/hash-type-info/hash-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/hash-type-info/hash-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/list-type-info/list-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/list-type-info/list-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/rejson-rl-type-info/rejson-rl-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/rejson-rl-type-info/rejson-rl-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/set-type-info/set-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/set-type-info/set-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/stream-type-info/stream-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/stream-type-info/stream-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/string-type-info/string-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/string-type-info/string-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/ts-type-info/ts-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/ts-type-info/ts-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/unsupported-type-info/unsupported-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/unsupported-type-info/unsupported-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/z-set-type-info/z-set-type-info.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/key-info-manager/strategies/z-set-type-info/z-set-type-info.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/keys-business.service.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.interface.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/scanner/scanner.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/abstract.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/abstract.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/cluster.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/keys-business/scanner/strategies/standalone.strategy.ts create mode 100644 redisinsight/api/src/modules/browser/services/list-business/list-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/list-business/list-business.service.ts create mode 100644 redisinsight/api/src/modules/browser/services/rejson-rl-business/rejson-rl-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/rejson-rl-business/rejson-rl-business.service.ts create mode 100644 redisinsight/api/src/modules/browser/services/set-business/set-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/set-business/set-business.service.ts create mode 100644 redisinsight/api/src/modules/browser/services/string-business/string-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/string-business/string-business.service.ts create mode 100644 redisinsight/api/src/modules/browser/services/z-set-business/z-set-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/browser/services/z-set-business/z-set-business.service.ts create mode 100644 redisinsight/api/src/modules/cli/cli.module.ts create mode 100644 redisinsight/api/src/modules/cli/constants/errors.ts create mode 100644 redisinsight/api/src/modules/cli/controllers/cli.controller.ts create mode 100644 redisinsight/api/src/modules/cli/decorators/api-cli-params.decorator.ts create mode 100644 redisinsight/api/src/modules/cli/dto/cli.dto.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-analytics/cli-analytics.service.spec.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-analytics/cli-analytics.service.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-business/cli-business.service.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter-manager.spec.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter-manager.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-business/output-formatter/output-formatter.interface.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/raw-formatter.strategy.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-business/output-formatter/strategies/text-formatter.strategy.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-tool/cli-tool.service.spec.ts create mode 100644 redisinsight/api/src/modules/cli/services/cli-tool/cli-tool.service.ts create mode 100644 redisinsight/api/src/modules/commands/commands-json.provider.spec.ts create mode 100644 redisinsight/api/src/modules/commands/commands-json.provider.ts create mode 100644 redisinsight/api/src/modules/commands/commands.controller.ts create mode 100644 redisinsight/api/src/modules/commands/commands.module.ts create mode 100644 redisinsight/api/src/modules/commands/commands.service.spec.ts create mode 100644 redisinsight/api/src/modules/commands/commands.service.ts create mode 100644 redisinsight/api/src/modules/core/core.module.ts create mode 100644 redisinsight/api/src/modules/core/encryption/encryption.service.spec.ts create mode 100644 redisinsight/api/src/modules/core/encryption/encryption.service.ts create mode 100644 redisinsight/api/src/modules/core/encryption/exceptions/encryption-service-error.exception.ts create mode 100644 redisinsight/api/src/modules/core/encryption/exceptions/index.ts create mode 100644 redisinsight/api/src/modules/core/encryption/exceptions/keytar-decryption-error.exception.ts create mode 100644 redisinsight/api/src/modules/core/encryption/exceptions/keytar-encryption-error.exception.ts create mode 100644 redisinsight/api/src/modules/core/encryption/exceptions/keytar-unavailable.exception.ts create mode 100644 redisinsight/api/src/modules/core/encryption/exceptions/unsupported-encryption-strategy.exception.ts create mode 100644 redisinsight/api/src/modules/core/encryption/models/encryption-result.ts create mode 100644 redisinsight/api/src/modules/core/encryption/models/index.ts create mode 100644 redisinsight/api/src/modules/core/encryption/strategies/encryption-strategy.interface.ts create mode 100644 redisinsight/api/src/modules/core/encryption/strategies/keytar-encryption.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/core/encryption/strategies/keytar-encryption.strategy.ts create mode 100644 redisinsight/api/src/modules/core/encryption/strategies/plain-encryption.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/core/encryption/strategies/plain-encryption.strategy.ts create mode 100644 redisinsight/api/src/modules/core/interceptors/timeout.interceptor.ts create mode 100644 redisinsight/api/src/modules/core/models/agreements.entity.ts create mode 100644 redisinsight/api/src/modules/core/models/ca-certificate.entity.ts create mode 100644 redisinsight/api/src/modules/core/models/client-certificate.entity.ts create mode 100644 redisinsight/api/src/modules/core/models/database-instance.entity.ts create mode 100644 redisinsight/api/src/modules/core/models/server-provider.interface.ts create mode 100644 redisinsight/api/src/modules/core/models/server.entity.ts create mode 100644 redisinsight/api/src/modules/core/models/settings-provider.interface.ts create mode 100644 redisinsight/api/src/modules/core/models/settings.entity.ts create mode 100644 redisinsight/api/src/modules/core/providers/server-on-premise/index.ts create mode 100644 redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.spec.ts create mode 100644 redisinsight/api/src/modules/core/providers/server-on-premise/server-on-premise.service.ts create mode 100644 redisinsight/api/src/modules/core/providers/settings-on-premise/index.ts create mode 100644 redisinsight/api/src/modules/core/providers/settings-on-premise/settings-on-premise.service.spec.ts create mode 100644 redisinsight/api/src/modules/core/providers/settings-on-premise/settings-on-premise.service.ts create mode 100644 redisinsight/api/src/modules/core/repositories/agreements.repository.ts create mode 100644 redisinsight/api/src/modules/core/repositories/base/base.interface.repository.ts create mode 100644 redisinsight/api/src/modules/core/repositories/server.repository.ts create mode 100644 redisinsight/api/src/modules/core/repositories/settings.repository.ts create mode 100644 redisinsight/api/src/modules/core/services/analytics/analytics.service.spec.ts create mode 100644 redisinsight/api/src/modules/core/services/analytics/analytics.service.ts create mode 100644 redisinsight/api/src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/core/services/certificates/ca-cert-business/ca-cert-business.service.ts create mode 100644 redisinsight/api/src/modules/core/services/certificates/client-cert-business/client-cert-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/core/services/certificates/client-cert-business/client-cert-business.service.ts create mode 100644 redisinsight/api/src/modules/core/services/redis/redis.service.spec.ts create mode 100644 redisinsight/api/src/modules/core/services/redis/redis.service.ts create mode 100644 redisinsight/api/src/modules/core/services/settings-analytics/settings-analytics.service.spec.ts create mode 100644 redisinsight/api/src/modules/core/services/settings-analytics/settings-analytics.service.ts create mode 100644 redisinsight/api/src/modules/instances/controllers/certificates/certificates.controller.ts create mode 100644 redisinsight/api/src/modules/instances/controllers/instances/instances.controller.ts create mode 100644 redisinsight/api/src/modules/instances/dto/database-instance.dto.ts create mode 100644 redisinsight/api/src/modules/instances/dto/database-overview.dto.ts create mode 100644 redisinsight/api/src/modules/instances/dto/redis-enterprise-cloud.dto.ts create mode 100644 redisinsight/api/src/modules/instances/dto/redis-enterprise-cluster.dto.ts create mode 100644 redisinsight/api/src/modules/instances/dto/redis-info.dto.ts create mode 100644 redisinsight/api/src/modules/instances/dto/redis-sentinel.dto.ts create mode 100644 redisinsight/api/src/modules/instances/instances.module.ts create mode 100644 redisinsight/api/src/modules/plugin/plugin.controller.ts create mode 100644 redisinsight/api/src/modules/plugin/plugin.module.ts create mode 100644 redisinsight/api/src/modules/plugin/plugin.response.ts create mode 100644 redisinsight/api/src/modules/plugin/plugin.service.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/controllers/cluster.controller.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/dto/cluster.dto.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-database.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-subscriptions.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/utils/redis-enterprise-converter.spec.ts create mode 100644 redisinsight/api/src/modules/redis-enterprise/utils/redis-enterprise-converter.ts create mode 100644 redisinsight/api/src/modules/redis-sentinel/controllers/sentinel.controller.ts create mode 100644 redisinsight/api/src/modules/redis-sentinel/dto/sentinel.dto.ts create mode 100644 redisinsight/api/src/modules/redis-sentinel/models/sentinel.ts create mode 100644 redisinsight/api/src/modules/redis-sentinel/redis-sentinel.module.ts create mode 100644 redisinsight/api/src/modules/shared/services/autodiscovery-analytics.service/autodiscovery-analytics.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/autodiscovery-analytics.service/autodiscovery-analytics.service.ts create mode 100644 redisinsight/api/src/modules/shared/services/base/redis-consumer.abstract.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/base/redis-consumer.abstract.service.ts create mode 100644 redisinsight/api/src/modules/shared/services/base/telemetry.base.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/base/telemetry.base.service.ts create mode 100644 redisinsight/api/src/modules/shared/services/configuration-business/configuration-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/configuration-business/configuration-business.service.ts create mode 100644 redisinsight/api/src/modules/shared/services/instances-business/database.provider.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/instances-business/databases.provider.ts create mode 100644 redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/instances-business/instances-analytics.service.ts create mode 100644 redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/instances-business/instances-business.service.ts create mode 100644 redisinsight/api/src/modules/shared/services/instances-business/overview.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/instances-business/overview.service.ts create mode 100644 redisinsight/api/src/modules/shared/services/redis-cloud-business/redis-cloud-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/redis-cloud-business/redis-cloud-business.service.ts create mode 100644 redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/redis-enterprise-business/redis-enterprise-business.service.ts create mode 100644 redisinsight/api/src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service.spec.ts create mode 100644 redisinsight/api/src/modules/shared/services/redis-sentinel-business/redis-sentinel-business.service.ts create mode 100644 redisinsight/api/src/modules/shared/shared.module.ts create mode 100644 redisinsight/api/src/modules/shared/utils/database-entity-converter.ts create mode 100644 redisinsight/api/src/utils/analytics-helper.spec.ts create mode 100644 redisinsight/api/src/utils/analytics-helper.ts create mode 100644 redisinsight/api/src/utils/catch-redis-errors.ts create mode 100644 redisinsight/api/src/utils/cli-helper.spec.ts create mode 100644 redisinsight/api/src/utils/cli-helper.ts create mode 100644 redisinsight/api/src/utils/config.spec.ts create mode 100644 redisinsight/api/src/utils/config.ts create mode 100644 redisinsight/api/src/utils/converter.spec.ts create mode 100644 redisinsight/api/src/utils/converter.ts create mode 100644 redisinsight/api/src/utils/glob-pattern-helper.spec.ts create mode 100644 redisinsight/api/src/utils/glob-pattern-helper.ts create mode 100644 redisinsight/api/src/utils/hosting-provider-helper.spec.ts create mode 100644 redisinsight/api/src/utils/hosting-provider-helper.ts create mode 100644 redisinsight/api/src/utils/index.ts create mode 100644 redisinsight/api/src/utils/logsFormatter.ts create mode 100644 redisinsight/api/src/utils/redis-connection-helper.ts create mode 100644 redisinsight/api/src/utils/redis-reply-converter.spec.ts create mode 100644 redisinsight/api/src/utils/redis-reply-converter.ts create mode 100644 redisinsight/api/src/validators/caCertCollision.validator.spec.ts create mode 100644 redisinsight/api/src/validators/caCertCollision.validator.ts create mode 100644 redisinsight/api/src/validators/clientCertCollision.validator.spec.ts create mode 100644 redisinsight/api/src/validators/clientCertCollision.validator.ts create mode 100644 redisinsight/api/src/validators/index.ts create mode 100644 redisinsight/api/src/validators/serializedJson.validator.spec.ts create mode 100644 redisinsight/api/src/validators/serializedJson.validator.ts create mode 100644 redisinsight/api/test/api/api.deps.init.ts create mode 100644 redisinsight/api/test/api/api.tsconfig.json create mode 100644 redisinsight/api/test/api/cli/POST-instance-id-cli-uuid-send_cluster_command.test.ts create mode 100644 redisinsight/api/test/api/cli/POST-instance-id-cli-uuid-send_command.test.ts create mode 100644 redisinsight/api/test/api/cli/POST-instance-id-cli.test.ts create mode 100644 redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts create mode 100644 redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_databases.test.ts create mode 100644 redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts create mode 100644 redisinsight/api/test/api/commands/GET-commands.test.ts create mode 100644 redisinsight/api/test/api/deps.ts create mode 100644 redisinsight/api/test/api/enterprise/POST-redis-enterprise-cluster-get_dbs.test.ts create mode 100644 redisinsight/api/test/api/hash/DELETE-instance-id-hash-fields.test.ts create mode 100644 redisinsight/api/test/api/hash/POST-instance-id-hash-get_fields.test.ts create mode 100644 redisinsight/api/test/api/hash/POST-instance-id-hash.test.ts create mode 100644 redisinsight/api/test/api/hash/PUT-instance-id-hash.test.ts create mode 100644 redisinsight/api/test/api/info/GET-info-cli-blocking-commands.test.ts create mode 100644 redisinsight/api/test/api/info/GET-info-cli-unsupported-commands.test.ts create mode 100644 redisinsight/api/test/api/info/GET-info.test.ts create mode 100644 redisinsight/api/test/api/instance/DELETE-instance-id.test.ts create mode 100644 redisinsight/api/test/api/instance/DELETE-instance.test.ts create mode 100644 redisinsight/api/test/api/instance/GET-instance-id-connect.test.ts create mode 100644 redisinsight/api/test/api/instance/GET-instance-id-info.test.ts create mode 100644 redisinsight/api/test/api/instance/GET-instance-id-overview.test.ts create mode 100644 redisinsight/api/test/api/instance/GET-instance-id-plugin-commands.test.ts create mode 100644 redisinsight/api/test/api/instance/GET-instance.test.ts create mode 100644 redisinsight/api/test/api/instance/PATCH-instance-id-name.test.ts create mode 100644 redisinsight/api/test/api/instance/POST-instance-sentinel_masters.test.ts create mode 100644 redisinsight/api/test/api/instance/POST-instance.test.ts create mode 100644 redisinsight/api/test/api/instance/PUT-instance-id.test.ts create mode 100644 redisinsight/api/test/api/keys/DELETE-instance-id-keys.test.ts create mode 100644 redisinsight/api/test/api/keys/GET-instance-id-keys.test.ts create mode 100644 redisinsight/api/test/api/keys/PATCH-instance-id-keys-name.test.ts create mode 100644 redisinsight/api/test/api/keys/PATCH-instance-id-keys-ttl.test.ts create mode 100644 redisinsight/api/test/api/keys/POST-instance-id-keys-get_info.test.ts create mode 100644 redisinsight/api/test/api/list/DELETE-instance-id-list-elements.test.ts create mode 100644 redisinsight/api/test/api/list/PATCH-instance-id-list.test.ts create mode 100644 redisinsight/api/test/api/list/POST-instance-id-list-get_elements-index.test.ts create mode 100644 redisinsight/api/test/api/list/POST-instance-id-list-get_elements.test.ts create mode 100644 redisinsight/api/test/api/list/POST-instance-id-list.test.ts create mode 100644 redisinsight/api/test/api/list/PUT-instance-id-list.test.ts create mode 100644 redisinsight/api/test/api/plugins/GET-plugins.test.ts create mode 100644 redisinsight/api/test/api/rejson-rl/DELETE-instance-id-rejson_rl.test.ts create mode 100644 redisinsight/api/test/api/rejson-rl/PATCH-instance-id-rejson_rl-arrappend.test.ts create mode 100644 redisinsight/api/test/api/rejson-rl/PATCH-instance-id-rejson_rl-set.test.ts create mode 100644 redisinsight/api/test/api/rejson-rl/POST-instance-id-rejson_rl-get.test.ts create mode 100644 redisinsight/api/test/api/rejson-rl/POST-instance-id-rejson_rl.test.ts create mode 100644 redisinsight/api/test/api/reporters.json create mode 100644 redisinsight/api/test/api/set/DELETE-instance-id-set-members.test.ts create mode 100644 redisinsight/api/test/api/set/POST-instance-id-set-get_members.test.ts create mode 100644 redisinsight/api/test/api/set/POST-instance-id-set.test.ts create mode 100644 redisinsight/api/test/api/set/PUT-instance-id-set.test.ts create mode 100644 redisinsight/api/test/api/settings/GET-settings-agreements-spec.test.ts create mode 100644 redisinsight/api/test/api/settings/GET-settings.test.ts create mode 100644 redisinsight/api/test/api/settings/PATCH-settings.test.ts create mode 100644 redisinsight/api/test/api/string/POST-instance-id-string.test.ts create mode 100644 redisinsight/api/test/api/string/PUT-instance-id-string.test.ts create mode 100644 redisinsight/api/test/api/z-set/DELETE-instance-id-zSet-members.test.ts create mode 100644 redisinsight/api/test/api/z-set/PATCH-instance-id-zSet.test.ts create mode 100644 redisinsight/api/test/api/z-set/POST-instance-id-zSet-get_members.test.ts create mode 100644 redisinsight/api/test/api/z-set/POST-instance-id-zSet-search.test.ts create mode 100644 redisinsight/api/test/api/z-set/POST-instance-id-zSet.test.ts create mode 100644 redisinsight/api/test/api/z-set/PUT-instance-id-zSet.test.ts create mode 100644 redisinsight/api/test/helpers/cloud.ts create mode 100644 redisinsight/api/test/helpers/constants.ts create mode 100644 redisinsight/api/test/helpers/data/redis.ts create mode 100644 redisinsight/api/test/helpers/local-db.ts create mode 100644 redisinsight/api/test/helpers/redis.ts create mode 100644 redisinsight/api/test/helpers/server.ts create mode 100644 redisinsight/api/test/helpers/test.ts create mode 100644 redisinsight/api/test/helpers/test/conditionalIgnore.ts create mode 100644 redisinsight/api/test/helpers/test/dataGenerator.ts create mode 100644 redisinsight/api/test/helpers/utils.ts create mode 100644 redisinsight/api/test/test-runs/cloud-st/.env create mode 100644 redisinsight/api/test/test-runs/cloud-st/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/docker.build.env create mode 100644 redisinsight/api/test/test-runs/docker.build.yml create mode 100644 redisinsight/api/test/test-runs/local.build.env create mode 100644 redisinsight/api/test/test-runs/local.build.yml create mode 100644 redisinsight/api/test/test-runs/mods-preview/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/oss-clu-tls/.env create mode 100644 redisinsight/api/test/test-runs/oss-clu-tls/Dockerfile create mode 100644 redisinsight/api/test/test-runs/oss-clu-tls/certs/redis.crt create mode 100644 redisinsight/api/test/test-runs/oss-clu-tls/certs/redis.key create mode 100644 redisinsight/api/test/test-runs/oss-clu-tls/certs/redisCA.crt create mode 100644 redisinsight/api/test/test-runs/oss-clu-tls/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/oss-clu/.env create mode 100644 redisinsight/api/test/test-runs/oss-clu/Dockerfile create mode 100644 redisinsight/api/test/test-runs/oss-clu/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/oss-sent/.env create mode 100644 redisinsight/api/test/test-runs/oss-sent/Dockerfile create mode 100644 redisinsight/api/test/test-runs/oss-sent/docker-compose.yml create mode 100755 redisinsight/api/test/test-runs/oss-sent/entrypoint.sh create mode 100644 redisinsight/api/test/test-runs/oss-sent/sentinel.conf create mode 100644 redisinsight/api/test/test-runs/oss-st-5-pass/.env create mode 100644 redisinsight/api/test/test-runs/oss-st-5-pass/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/oss-st-5/Dockerfile create mode 100644 redisinsight/api/test/test-runs/oss-st-5/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/oss-st-5/redis.conf create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls-auth/.env create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls-auth/Dockerfile create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redis.crt create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redis.key create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/redisCA.crt create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/user.crt create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls-auth/certs/user.key create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls-auth/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls/.env create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls/Dockerfile create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls/certs/redis.crt create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls/certs/redis.key create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls/certs/redisCA.crt create mode 100644 redisinsight/api/test/test-runs/oss-st-6-tls/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/oss-st-6/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/re-clu/.env create mode 100644 redisinsight/api/test/test-runs/re-clu/Dockerfile create mode 100644 redisinsight/api/test/test-runs/re-clu/README.md create mode 100644 redisinsight/api/test/test-runs/re-clu/cert.pem create mode 100644 redisinsight/api/test/test-runs/re-clu/create_dbs.py create mode 100644 redisinsight/api/test/test-runs/re-clu/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/re-clu/run_re_and_create_db.sh create mode 100644 redisinsight/api/test/test-runs/re-st/.env create mode 100644 redisinsight/api/test/test-runs/re-st/Dockerfile create mode 100644 redisinsight/api/test/test-runs/re-st/README.md create mode 100644 redisinsight/api/test/test-runs/re-st/cert.pem create mode 100644 redisinsight/api/test/test-runs/re-st/create_dbs.py create mode 100644 redisinsight/api/test/test-runs/re-st/docker-compose.yml create mode 100644 redisinsight/api/test/test-runs/re-st/run_re_and_create_db.sh create mode 100755 redisinsight/api/test/test-runs/run-all.sh create mode 100755 redisinsight/api/test/test-runs/start-test-run.sh create mode 100644 redisinsight/api/test/test-runs/test-docker-entry.sh create mode 100644 redisinsight/api/test/test-runs/test.Dockerfile create mode 100755 redisinsight/api/test/test-runs/wait-for-it.sh create mode 100644 redisinsight/api/tsconfig.build.json create mode 100644 redisinsight/api/tsconfig.build.prod.json create mode 100644 redisinsight/api/tsconfig.json create mode 100644 redisinsight/api/yarn.lock create mode 100644 redisinsight/index.html create mode 100644 redisinsight/main.dev.ts create mode 100644 redisinsight/main.prod.js.LICENSE.txt create mode 100644 redisinsight/main.renderer.ts create mode 100644 redisinsight/menu.ts create mode 100644 redisinsight/package.json create mode 100644 redisinsight/tray.ts create mode 100644 redisinsight/ui/.eslintignore create mode 100644 redisinsight/ui/.eslintrc.js create mode 100644 redisinsight/ui/README.md create mode 100644 redisinsight/ui/index.html.ejs create mode 100644 redisinsight/ui/index.tsx create mode 100644 redisinsight/ui/indexElectron.tsx create mode 100644 redisinsight/ui/src/App.scss create mode 100644 redisinsight/ui/src/App.spec.tsx create mode 100644 redisinsight/ui/src/App.tsx create mode 100644 redisinsight/ui/src/Router.spec.tsx create mode 100644 redisinsight/ui/src/Router.tsx create mode 100644 redisinsight/ui/src/assets/assets.d.ts create mode 100644 redisinsight/ui/src/assets/favicon.ico create mode 100644 redisinsight/ui/src/assets/fonts/graphik/Graphik-Light.otf create mode 100644 redisinsight/ui/src/assets/fonts/graphik/Graphik-LightItalic.otf create mode 100644 redisinsight/ui/src/assets/fonts/graphik/Graphik-Medium.otf create mode 100644 redisinsight/ui/src/assets/fonts/graphik/Graphik-MediumItalic.otf create mode 100644 redisinsight/ui/src/assets/fonts/graphik/Graphik-Regular.otf create mode 100644 redisinsight/ui/src/assets/fonts/graphik/Graphik-RegularItalic.otf create mode 100644 redisinsight/ui/src/assets/fonts/graphik/Graphik-Semibold.otf create mode 100644 redisinsight/ui/src/assets/fonts/graphik/Graphik-SemiboldItalic.otf create mode 100644 redisinsight/ui/src/assets/fonts/inconsolata/Inconsolata-Bold.ttf create mode 100644 redisinsight/ui/src/assets/fonts/inconsolata/Inconsolata-Regular.ttf create mode 100644 redisinsight/ui/src/assets/img/active_auto.svg create mode 100644 redisinsight/ui/src/assets/img/active_manual.svg create mode 100644 redisinsight/ui/src/assets/img/dark_logo.svg create mode 100644 redisinsight/ui/src/assets/img/light_logo.svg create mode 100644 redisinsight/ui/src/assets/img/light_theme/active_auto.svg create mode 100644 redisinsight/ui/src/assets/img/light_theme/active_manual.svg create mode 100644 redisinsight/ui/src/assets/img/light_theme/n_active_auto.svg create mode 100644 redisinsight/ui/src/assets/img/light_theme/n_active_manual.svg create mode 100644 redisinsight/ui/src/assets/img/logo.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisAIDark.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisAILight.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisBloomDark.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisBloomLight.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisGearsDark.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisGearsLight.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisGraphDark.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisGraphLight.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisJSONDark.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisJSONLight.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisSearchDark.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisSearchLight.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisTimeSeriesDark.svg create mode 100644 redisinsight/ui/src/assets/img/modules/RedisTimeSeriesLight.svg create mode 100644 redisinsight/ui/src/assets/img/modules/UnknownDark.svg create mode 100644 redisinsight/ui/src/assets/img/modules/UnknownLight.svg create mode 100644 redisinsight/ui/src/assets/img/not_active_auto.svg create mode 100644 redisinsight/ui/src/assets/img/not_active_manual.svg create mode 100644 redisinsight/ui/src/assets/img/options/Active-ActiveDark.svg create mode 100644 redisinsight/ui/src/assets/img/options/Active-ActiveLight.svg create mode 100644 redisinsight/ui/src/assets/img/options/RedisOnFlashDark.svg create mode 100644 redisinsight/ui/src/assets/img/options/RedisOnFlashLight.svg create mode 100644 redisinsight/ui/src/assets/img/overview/input_light.svg create mode 100644 redisinsight/ui/src/assets/img/overview/input_tip.svg create mode 100644 redisinsight/ui/src/assets/img/overview/key_dark.svg create mode 100644 redisinsight/ui/src/assets/img/overview/key_light.svg create mode 100644 redisinsight/ui/src/assets/img/overview/key_tip.svg create mode 100644 redisinsight/ui/src/assets/img/overview/measure_dark.svg create mode 100644 redisinsight/ui/src/assets/img/overview/measure_light.svg create mode 100644 redisinsight/ui/src/assets/img/overview/measure_tip.svg create mode 100644 redisinsight/ui/src/assets/img/overview/memory_dark.svg create mode 100644 redisinsight/ui/src/assets/img/overview/memory_light.svg create mode 100644 redisinsight/ui/src/assets/img/overview/output_light.svg create mode 100644 redisinsight/ui/src/assets/img/overview/output_tip.svg create mode 100644 redisinsight/ui/src/assets/img/overview/time_dark.svg create mode 100644 redisinsight/ui/src/assets/img/overview/time_light.svg create mode 100644 redisinsight/ui/src/assets/img/overview/time_tip.svg create mode 100644 redisinsight/ui/src/assets/img/overview/user_dark.svg create mode 100644 redisinsight/ui/src/assets/img/overview/user_light.svg create mode 100644 redisinsight/ui/src/assets/img/overview/user_tip.svg create mode 100644 redisinsight/ui/src/assets/img/resize-corner.svg create mode 100644 redisinsight/ui/src/assets/img/sidebar/browser.svg create mode 100644 redisinsight/ui/src/assets/img/sidebar/browser_active.svg create mode 100644 redisinsight/ui/src/assets/img/sidebar/database.svg create mode 100644 redisinsight/ui/src/assets/img/sidebar/database_active.svg create mode 100644 redisinsight/ui/src/assets/img/sidebar/settings.svg create mode 100644 redisinsight/ui/src/assets/img/sidebar/settings_active.svg create mode 100644 redisinsight/ui/src/assets/img/sidebar/workbench.svg create mode 100644 redisinsight/ui/src/assets/img/sidebar/workbench_active.svg create mode 100644 redisinsight/ui/src/assets/img/welcome_bg_dark.jpg create mode 100644 redisinsight/ui/src/assets/img/welcome_bg_light.jpg create mode 100644 redisinsight/ui/src/assets/img/workbench/RediSearchNotAvailableDark.jpg create mode 100644 redisinsight/ui/src/assets/img/workbench/RediSearchNotAvailableLight.jpg create mode 100644 redisinsight/ui/src/assets/img/workbench/default_view_dark.svg create mode 100644 redisinsight/ui/src/assets/img/workbench/default_view_light.svg create mode 100644 redisinsight/ui/src/assets/img/workbench/table_view_icon_dark.svg create mode 100644 redisinsight/ui/src/assets/img/workbench/table_view_icon_light.svg create mode 100644 redisinsight/ui/src/components/CircularSpinnerPage.tsx create mode 100644 redisinsight/ui/src/components/ContentEditable.tsx create mode 100644 redisinsight/ui/src/components/action-bar/ActionBar.spec.tsx create mode 100644 redisinsight/ui/src/components/action-bar/ActionBar.tsx create mode 100644 redisinsight/ui/src/components/action-bar/styles.module.scss create mode 100644 redisinsight/ui/src/components/advanced-settings/AdvancedSettings.spec.tsx create mode 100644 redisinsight/ui/src/components/advanced-settings/AdvancedSettings.tsx create mode 100644 redisinsight/ui/src/components/advanced-settings/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/Cli/Cli.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/Cli/Cli.tsx create mode 100644 redisinsight/ui/src/components/cli/Cli/index.ts create mode 100644 redisinsight/ui/src/components/cli/Cli/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/CliWrapper.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/CliWrapper.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-body/CliBody/CliBody.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-body/CliBody/CliBody.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-body/CliBody/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-body/CliBody/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-body/CliBodyWrapper.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-body/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-command-info/CliCommandInfo.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-command-info/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-command-info/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/components/cli-header-minimized/CliHeaderMinimized.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-header-minimized/CliHeaderMinimized.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-header-minimized/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-header/CliHeader.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-header/CliHeader.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-header/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-header/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/CliHelper.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/CliHelper.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-helper/CliHelper/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/components/cli-helper/CliHelperWrapper.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-helper/CliHelperWrapper.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-helper/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/CliAutocomplete.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/CliAutocomplete.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliAutocomplete/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliInput/CliInput.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliInput/CliInput.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliInput/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliInput/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliInputWrapper.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/CliInputWrapper.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-input/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-search-output/CliSearchOutput.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-search-output/CliSearchOutput.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-search-output/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-search-output/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/CliSearchFilter.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/CliSearchFilter.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/constants.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchFilter/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/CliSearchInput.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/CliSearchInput.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchInput/styles.module.scss create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchWrapper.spec.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/CliSearchWrapper.tsx create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/index.ts create mode 100644 redisinsight/ui/src/components/cli/components/cli-search/styles.module.scss create mode 100644 redisinsight/ui/src/components/config/Config.spec.tsx create mode 100644 redisinsight/ui/src/components/config/Config.tsx create mode 100644 redisinsight/ui/src/components/config/index.ts create mode 100644 redisinsight/ui/src/components/consents-settings/ConsentsSettings.spec.tsx create mode 100644 redisinsight/ui/src/components/consents-settings/ConsentsSettings.tsx create mode 100644 redisinsight/ui/src/components/consents-settings/ConsentsSettingsPopup/ConsentsSettingsPopup.spec.tsx create mode 100644 redisinsight/ui/src/components/consents-settings/ConsentsSettingsPopup/ConsentsSettingsPopup.tsx create mode 100644 redisinsight/ui/src/components/consents-settings/index.ts create mode 100644 redisinsight/ui/src/components/consents-settings/styles.module.scss create mode 100644 redisinsight/ui/src/components/css.d.ts create mode 100644 redisinsight/ui/src/components/database-list-modules/DatabaseListModules.spec.tsx create mode 100644 redisinsight/ui/src/components/database-list-modules/DatabaseListModules.tsx create mode 100644 redisinsight/ui/src/components/database-list-modules/styles.module.scss create mode 100644 redisinsight/ui/src/components/database-list-options/DatabaseListOptions.spec.tsx create mode 100644 redisinsight/ui/src/components/database-list-options/DatabaseListOptions.tsx create mode 100644 redisinsight/ui/src/components/database-list-options/styles.module.scss create mode 100644 redisinsight/ui/src/components/database-overview/DatabaseOverview.spec.tsx create mode 100644 redisinsight/ui/src/components/database-overview/DatabaseOverview.tsx create mode 100644 redisinsight/ui/src/components/database-overview/components/OverviewItems.tsx create mode 100644 redisinsight/ui/src/components/database-overview/components/icons.ts create mode 100644 redisinsight/ui/src/components/database-overview/styles.module.scss create mode 100644 redisinsight/ui/src/components/divider/Divider.spec.tsx create mode 100644 redisinsight/ui/src/components/divider/Divider.tsx create mode 100644 redisinsight/ui/src/components/divider/styles.module.scss create mode 100644 redisinsight/ui/src/components/field-message/FieldMessage.spec.tsx create mode 100644 redisinsight/ui/src/components/field-message/FieldMessage.tsx create mode 100644 redisinsight/ui/src/components/field-message/styles.module.scss create mode 100644 redisinsight/ui/src/components/group-badge/GroupBadge.tsx create mode 100644 redisinsight/ui/src/components/index.ts create mode 100644 redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.spec.tsx create mode 100644 redisinsight/ui/src/components/inline-item-editor/InlineItemEditor.tsx create mode 100644 redisinsight/ui/src/components/inline-item-editor/styles.module.scss create mode 100644 redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.spec.tsx create mode 100644 redisinsight/ui/src/components/input-field-sentinel/InputFieldSentinel.tsx create mode 100644 redisinsight/ui/src/components/input-field-sentinel/styles.module.scss create mode 100644 redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx create mode 100644 redisinsight/ui/src/components/instance-header/InstanceHeader.tsx create mode 100644 redisinsight/ui/src/components/instance-header/index.ts create mode 100644 redisinsight/ui/src/components/instance-header/styles.module.scss create mode 100644 redisinsight/ui/src/components/keyboard-shortcut/KeyboardShortcut.spec.tsx create mode 100644 redisinsight/ui/src/components/keyboard-shortcut/KeyboardShortcut.tsx create mode 100644 redisinsight/ui/src/components/keyboard-shortcut/styles.module.scss create mode 100644 redisinsight/ui/src/components/main-router/MainRouter.tsx create mode 100644 redisinsight/ui/src/components/main-router/interfaces.ts create mode 100644 redisinsight/ui/src/components/main/MainComponent.tsx create mode 100644 redisinsight/ui/src/components/message-bar/MessageBar.spec.tsx create mode 100644 redisinsight/ui/src/components/message-bar/MessageBar.tsx create mode 100644 redisinsight/ui/src/components/message-bar/styles.module.scss create mode 100644 redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx create mode 100644 redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx create mode 100644 redisinsight/ui/src/components/navigation-menu/styles.module.scss create mode 100644 redisinsight/ui/src/components/notifications/Notifications.spec.tsx create mode 100644 redisinsight/ui/src/components/notifications/Notifications.tsx create mode 100644 redisinsight/ui/src/components/notifications/components/DefaultErrorContent.tsx create mode 100644 redisinsight/ui/src/components/notifications/components/EncryptionErrorContent.tsx create mode 100644 redisinsight/ui/src/components/notifications/components/index.ts create mode 100644 redisinsight/ui/src/components/notifications/error-messages.tsx create mode 100644 redisinsight/ui/src/components/notifications/styles.module.scss create mode 100644 redisinsight/ui/src/components/notifications/success-messages.tsx create mode 100644 redisinsight/ui/src/components/page-breadcrumbs/PageBreadcrumbs.spec.tsx create mode 100644 redisinsight/ui/src/components/page-breadcrumbs/PageBreadcrumbs.tsx create mode 100644 redisinsight/ui/src/components/page-breadcrumbs/index.ts create mode 100644 redisinsight/ui/src/components/page-breadcrumbs/styles.module.scss create mode 100644 redisinsight/ui/src/components/page-header/PageHeader.module.scss create mode 100644 redisinsight/ui/src/components/page-header/PageHeader.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCard.spec.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCard.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCliPlugin/index.ts create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCliPlugin/styles.module.scss create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCliResult/QueryCardCliResult.spec.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCliResult/QueryCardCliResult.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCliResult/index.ts create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCliResult/styles.module.scss create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.spec.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCommonResult/index.ts create mode 100644 redisinsight/ui/src/components/query-card/QueryCardCommonResult/styles.module.scss create mode 100644 redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.spec.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardHeader/index.ts create mode 100644 redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss create mode 100644 redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.spec.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.tsx create mode 100644 redisinsight/ui/src/components/query-card/QueryCardTooltip/index.ts create mode 100644 redisinsight/ui/src/components/query-card/QueryCardTooltip/styles.module.scss create mode 100644 redisinsight/ui/src/components/query-card/index.ts create mode 100644 redisinsight/ui/src/components/query-card/styles.module.scss create mode 100644 redisinsight/ui/src/components/query/Query/Query.spec.tsx create mode 100644 redisinsight/ui/src/components/query/Query/Query.tsx create mode 100644 redisinsight/ui/src/components/query/Query/index.ts create mode 100644 redisinsight/ui/src/components/query/Query/styles.module.scss create mode 100644 redisinsight/ui/src/components/query/QueryWrapper.spec.tsx create mode 100644 redisinsight/ui/src/components/query/QueryWrapper.tsx create mode 100644 redisinsight/ui/src/components/query/index.ts create mode 100644 redisinsight/ui/src/components/table-column-search-trigger/TableColumnSearchTrigger.spec.tsx create mode 100644 redisinsight/ui/src/components/table-column-search-trigger/TableColumnSearchTrigger.tsx create mode 100644 redisinsight/ui/src/components/table-column-search-trigger/styles.module.scss create mode 100644 redisinsight/ui/src/components/table-column-search/TableColumnSearch.spec.tsx create mode 100644 redisinsight/ui/src/components/table-column-search/TableColumnSearch.tsx create mode 100644 redisinsight/ui/src/components/table-column-search/styles.module.scss create mode 100644 redisinsight/ui/src/components/virtual-table/VirtualTable.spec.tsx create mode 100644 redisinsight/ui/src/components/virtual-table/VirtualTable.tsx create mode 100644 redisinsight/ui/src/components/virtual-table/interfaces.ts create mode 100644 redisinsight/ui/src/components/virtual-table/styles.module.scss create mode 100644 redisinsight/ui/src/constants/allRedisModules.json create mode 100644 redisinsight/ui/src/constants/api.ts create mode 100644 redisinsight/ui/src/constants/apiErrors.ts create mode 100644 redisinsight/ui/src/constants/apiStatusCode.ts create mode 100644 redisinsight/ui/src/constants/breadcrumbs.ts create mode 100644 redisinsight/ui/src/constants/cliOutput.tsx create mode 100644 redisinsight/ui/src/constants/commands.ts create mode 100644 redisinsight/ui/src/constants/commandsVersions.ts create mode 100644 redisinsight/ui/src/constants/env.ts create mode 100644 redisinsight/ui/src/constants/help-texts.tsx create mode 100644 redisinsight/ui/src/constants/index.ts create mode 100644 redisinsight/ui/src/constants/keyboardShortcuts.ts create mode 100644 redisinsight/ui/src/constants/keys.ts create mode 100644 redisinsight/ui/src/constants/mocks/mock-enablement-area.ts create mode 100644 redisinsight/ui/src/constants/mocks/mock-redis-commands.ts create mode 100644 redisinsight/ui/src/constants/monaco.ts create mode 100644 redisinsight/ui/src/constants/monacoRedis.ts create mode 100644 redisinsight/ui/src/constants/pages.ts create mode 100644 redisinsight/ui/src/constants/prop-types/keys.ts create mode 100644 redisinsight/ui/src/constants/prop-types/zset.ts create mode 100644 redisinsight/ui/src/constants/redisinsight.ts create mode 100644 redisinsight/ui/src/constants/regex.ts create mode 100644 redisinsight/ui/src/constants/routes.ts create mode 100644 redisinsight/ui/src/constants/storage.ts create mode 100644 redisinsight/ui/src/constants/table.ts create mode 100644 redisinsight/ui/src/constants/texts.tsx create mode 100644 redisinsight/ui/src/constants/themes.tsx create mode 100644 redisinsight/ui/src/constants/validationErrors.ts create mode 100644 redisinsight/ui/src/constants/workbenchPreselects.ts create mode 100644 redisinsight/ui/src/contexts/themeContext.tsx create mode 100644 redisinsight/ui/src/electron/AppElectron.tsx create mode 100644 redisinsight/ui/src/electron/components/ConfigElectron/ConfigElectron.tsx create mode 100644 redisinsight/ui/src/electron/components/ConfigElectron/index.tsx create mode 100644 redisinsight/ui/src/electron/components/index.ts create mode 100644 redisinsight/ui/src/electron/constants/index.ts create mode 100644 redisinsight/ui/src/electron/constants/ipcEvent.ts create mode 100644 redisinsight/ui/src/electron/constants/storageElectron.ts create mode 100644 redisinsight/ui/src/electron/utils/index.ts create mode 100644 redisinsight/ui/src/electron/utils/ipcCheckUpdates.ts create mode 100644 redisinsight/ui/src/electron/utils/ipcDeleteStoreValues.ts create mode 100644 redisinsight/ui/src/hoc/extractRouter.hoc.tsx create mode 100644 redisinsight/ui/src/packages/clients-list-example/package.json create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/App.tsx create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/assets/table_view_icon_dark.svg create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/assets/table_view_icon_light.svg create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/components/TableResult/TableResult.spec.tsx create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/components/TableResult/TableResult.tsx create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/components/TableResult/index.ts create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/components/index.ts create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/icons/arrow_down.js create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/icons/arrow_left.js create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/icons/arrow_right.js create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/icons/check.js create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/icons/copy.js create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/icons/cross.js create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/icons/empty.js create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/index.html create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/main.tsx create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/response.json create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/styles/styles.scss create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/utils/cachedIcons.ts create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/utils/index.ts create mode 100644 redisinsight/ui/src/packages/clients-list-example/src/utils/parseResponse.ts create mode 100644 redisinsight/ui/src/packages/clients-list-example/yarn.lock create mode 100644 redisinsight/ui/src/packages/enablement-area/enablement-area.json create mode 100644 redisinsight/ui/src/packages/enablement-area/guides/document-capabilities/introduction.html create mode 100644 redisinsight/ui/src/packages/enablement-area/guides/document-capabilities/working-with-hashes.html create mode 100644 redisinsight/ui/src/packages/enablement-area/images/aggregations.png create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/aggregation-with-apply.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/combined-search-with-and.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/combined-search-with-geo-filter.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/combined-search-with-or.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/create-hash-index.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/crud-create.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/crud-delete.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/crud-read.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/crud-update.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/exact-text-search.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/field-specific-text-search.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/fuzzy-text-search.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/group-and-sort-by-aggregation.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/index-info.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/list-all-indexes.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/multiple-tags-and-search.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/multiple-tags-or-search.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/numeric-range-query.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/document-capabilities/working-with-hashes/tag-search.txt create mode 100644 redisinsight/ui/src/packages/enablement-area/scripts/manual.txt create mode 100644 redisinsight/ui/src/packages/redis-app-plugin-api/.gitignore create mode 100644 redisinsight/ui/src/packages/redis-app-plugin-api/helpers.js create mode 100644 redisinsight/ui/src/packages/redis-app-plugin-api/index.js create mode 100644 redisinsight/ui/src/packages/redis-app-plugin-api/package-lock.json create mode 100644 redisinsight/ui/src/packages/redis-app-plugin-api/package.json create mode 100644 redisinsight/ui/src/packages/redisearch/package.json create mode 100644 redisinsight/ui/src/packages/redisearch/src/App.tsx create mode 100644 redisinsight/ui/src/packages/redisearch/src/assets/table_view_icon_dark.svg create mode 100644 redisinsight/ui/src/packages/redisearch/src/assets/table_view_icon_light.svg create mode 100644 redisinsight/ui/src/packages/redisearch/src/components/GroupBadge/GroupBadge.tsx create mode 100644 redisinsight/ui/src/packages/redisearch/src/components/GroupBadge/index.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/components/TableInfoResult/TableInfoResult.spec.tsx create mode 100644 redisinsight/ui/src/packages/redisearch/src/components/TableInfoResult/TableInfoResult.tsx create mode 100644 redisinsight/ui/src/packages/redisearch/src/components/TableInfoResult/index.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/components/TableResult/TableResult.spec.tsx create mode 100644 redisinsight/ui/src/packages/redisearch/src/components/TableResult/TableResult.tsx create mode 100644 redisinsight/ui/src/packages/redisearch/src/components/TableResult/index.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/components/index.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/constants/constants.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/constants/index.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/icons/arrow_down.js create mode 100644 redisinsight/ui/src/packages/redisearch/src/icons/arrow_left.js create mode 100644 redisinsight/ui/src/packages/redisearch/src/icons/arrow_right.js create mode 100644 redisinsight/ui/src/packages/redisearch/src/icons/check.js create mode 100644 redisinsight/ui/src/packages/redisearch/src/icons/copy.js create mode 100644 redisinsight/ui/src/packages/redisearch/src/icons/cross.js create mode 100644 redisinsight/ui/src/packages/redisearch/src/icons/empty.js create mode 100644 redisinsight/ui/src/packages/redisearch/src/index.html create mode 100644 redisinsight/ui/src/packages/redisearch/src/main.tsx create mode 100644 redisinsight/ui/src/packages/redisearch/src/response.json create mode 100644 redisinsight/ui/src/packages/redisearch/src/response2.json create mode 100644 redisinsight/ui/src/packages/redisearch/src/response3.json create mode 100644 redisinsight/ui/src/packages/redisearch/src/responseInfo.json create mode 100644 redisinsight/ui/src/packages/redisearch/src/styles/styles.scss create mode 100644 redisinsight/ui/src/packages/redisearch/src/utils/cachedIcons.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/utils/formatLongName.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/utils/index.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/utils/parseResponse.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/utils/replaceSpaces.ts create mode 100644 redisinsight/ui/src/packages/redisearch/src/utils/tests/parseResponse.spec.ts create mode 100644 redisinsight/ui/src/packages/redisearch/yarn.lock create mode 100644 redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/BrowserPage.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-items-actions/AddItemsActions.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKey.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKey.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyCommonFields/AddKeyCommonFields.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyFooter/AddKeyFooter.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyFooter/AddKeyFooter.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyHash/AddKeyHash.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyList/AddKeyList.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyReJSON/AddKeyReJSON.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeySet/AddKeySet.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyString/AddKeyString.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/AddKeyZset/AddKeyZset.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/constants/fields-config.ts create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/constants/key-type-options.ts create mode 100644 redisinsight/ui/src/pages/browser/components/add-key/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/filter-key-type/constants.ts create mode 100644 redisinsight/ui/src/pages/browser/components/filter-key-type/index.ts create mode 100644 redisinsight/ui/src/pages/browser/components/filter-key-type/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/hash-details/HashDetails.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/hash-details/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-add-items/add-hash-fields/AddHashFields.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-add-items/add-hash-fields/AddHashFields.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-add-items/add-list-elements/AddListElements.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-add-items/add-set-members/AddSetMembers.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-add-items/add-zset-members/AddZsetMembers.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-add-items/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-header/KeyDetailsHeader.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-header/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-remove-items/remove-list-elements/RemoveListElements.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details-remove-items/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/key-details/KeyDetails/KeyDetails.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details/KeyDetails/KeyDetails.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details/KeyDetailsWrapper.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-details/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/key-list/KeyList.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/key-list/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/list-details/ListDetails.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/list-details/ListDetails.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/list-details/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/popover-delete/PopoverDelete.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/popover-delete/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/JSONArray/JSONArray.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/JSONInterfaces.ts create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/JSONObject/JSONObject.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/JSONScalar/JSONScalar.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/JSONUtils/JSONUtils.spec.ts create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/JSONUtils/JSONUtils.ts create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetails/RejsonDetails.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/RejsonDetailsWrapper.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/constants.ts create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/rejson-details/styles.scss create mode 100644 redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/search-key-list/index.ts create mode 100644 redisinsight/ui/src/pages/browser/components/search-key-list/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/set-details/SetDetails.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/set-details/SetDetails.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/set-details/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/string-details/StringDetails.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/string-details/StringDetails.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/string-details/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/unsupported-type-details/UnsupportedTypeDetails.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/unsupported-type-details/UnsupportedTypeDetails.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/unsupported-type-details/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.spec.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/zset-details/ZSetDetails.tsx create mode 100644 redisinsight/ui/src/pages/browser/components/zset-details/styles.module.scss create mode 100644 redisinsight/ui/src/pages/browser/index.ts create mode 100644 redisinsight/ui/src/pages/browser/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/HomePage.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/HomePage.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddDatabases/AddDatabasesContainer.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddDatabases/InstanceConnections/InstanceConnections.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddDatabases/InstanceConnections/InstanceConnections.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddDatabases/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceControls/AddInstanceControls.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceControls/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceFormWrapper.tsx create mode 100644 redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/CloudConnectionForm.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionForm/CloudConnectionForm.tsx create mode 100644 redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/CloudConnection/CloudConnectionFormWrapper.tsx create mode 100644 redisinsight/ui/src/pages/home/components/CloudConnection/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/ClusterConnectionForm.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionForm/ClusterConnectionForm.tsx create mode 100644 redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/ClusterConnection/ClusterConnectionFormWrapper.tsx create mode 100644 redisinsight/ui/src/pages/home/components/ClusterConnection/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/components/ClusterConnection/types.ts create mode 100644 redisinsight/ui/src/pages/home/components/DatabaseAlias/DatabaseAlias.tsx create mode 100644 redisinsight/ui/src/pages/home/components/DatabaseAlias/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/DatabaseAlias/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesList/DatabasesList.tsx create mode 100644 redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx create mode 100644 redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/components/HelpLinksMenu/HelpLinksMenu.tsx create mode 100644 redisinsight/ui/src/pages/home/components/HelpLinksMenu/index.ts create mode 100644 redisinsight/ui/src/pages/home/components/HelpLinksMenu/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.spec.tsx create mode 100644 redisinsight/ui/src/pages/home/components/WelcomeComponent/WelcomeComponent.tsx create mode 100644 redisinsight/ui/src/pages/home/components/WelcomeComponent/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/constants/help-links.ts create mode 100644 redisinsight/ui/src/pages/home/index.ts create mode 100644 redisinsight/ui/src/pages/home/styles.module.scss create mode 100644 redisinsight/ui/src/pages/home/styles.scss create mode 100644 redisinsight/ui/src/pages/index.ts create mode 100644 redisinsight/ui/src/pages/instance/InstancePage.spec.tsx create mode 100644 redisinsight/ui/src/pages/instance/InstancePage.tsx create mode 100644 redisinsight/ui/src/pages/instance/InstancePageRouter.spec.tsx create mode 100644 redisinsight/ui/src/pages/instance/InstancePageRouter.tsx create mode 100644 redisinsight/ui/src/pages/instance/index.ts create mode 100644 redisinsight/ui/src/pages/instance/styles.module.scss create mode 100644 redisinsight/ui/src/pages/redisCloud/RedisCloudPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/redisCloud/RedisCloudPage.tsx create mode 100644 redisinsight/ui/src/pages/redisCloud/index.ts create mode 100644 redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.spec.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/RedisCloudDatabases.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabases/index.ts create mode 100644 redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudDatabases/RedisCloudDatabasesPage.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudDatabases/index.ts create mode 100644 redisinsight/ui/src/pages/redisCloudDatabases/styles.module.scss create mode 100644 redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResult.spec.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResult.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResultPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudDatabasesResult/RedisCloudDatabasesResultPage.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudDatabasesResult/index.ts create mode 100644 redisinsight/ui/src/pages/redisCloudDatabasesResult/styles.module.scss create mode 100644 redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.spec.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptions/RedisCloudSubscriptions.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudSubscriptions/RedisCloudSubscriptionsPage.tsx create mode 100644 redisinsight/ui/src/pages/redisCloudSubscriptions/index.ts create mode 100644 redisinsight/ui/src/pages/redisCloudSubscriptions/styles.module.scss create mode 100644 redisinsight/ui/src/pages/redisCluster/RedisClusterDatabases.tsx create mode 100644 redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesPage.tsx create mode 100644 redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesResult.spec.tsx create mode 100644 redisinsight/ui/src/pages/redisCluster/RedisClusterDatabasesResult.tsx create mode 100644 redisinsight/ui/src/pages/redisCluster/index.ts create mode 100644 redisinsight/ui/src/pages/redisCluster/styles.module.scss create mode 100644 redisinsight/ui/src/pages/sentinel/SentinelPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/sentinel/SentinelPage.tsx create mode 100644 redisinsight/ui/src/pages/sentinel/index.ts create mode 100644 redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/sentinelDatabases/SentinelDatabasesPage.tsx create mode 100644 redisinsight/ui/src/pages/sentinelDatabases/components/SentinelDatabases/SentinelDatabases.spec.tsx create mode 100644 redisinsight/ui/src/pages/sentinelDatabases/components/SentinelDatabases/SentinelDatabases.tsx create mode 100644 redisinsight/ui/src/pages/sentinelDatabases/components/index.ts create mode 100644 redisinsight/ui/src/pages/sentinelDatabases/index.ts create mode 100644 redisinsight/ui/src/pages/sentinelDatabases/styles.module.scss create mode 100644 redisinsight/ui/src/pages/sentinelDatabasesResult/SentinelDatabasesResultPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/sentinelDatabasesResult/SentinelDatabasesResultPage.tsx create mode 100644 redisinsight/ui/src/pages/sentinelDatabasesResult/components/SentinelDatabasesResult/SentinelDatabasesResult.spec.tsx create mode 100644 redisinsight/ui/src/pages/sentinelDatabasesResult/components/SentinelDatabasesResult/SentinelDatabasesResult.tsx create mode 100644 redisinsight/ui/src/pages/sentinelDatabasesResult/components/SentinelDatabasesResult/styles.module.scss create mode 100644 redisinsight/ui/src/pages/sentinelDatabasesResult/components/index.ts create mode 100644 redisinsight/ui/src/pages/sentinelDatabasesResult/index.ts create mode 100644 redisinsight/ui/src/pages/settings/SettingsPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/settings/SettingsPage.tsx create mode 100644 redisinsight/ui/src/pages/settings/index.ts create mode 100644 redisinsight/ui/src/pages/settings/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/WorkbenchPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/EnablementArea.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/EnablementArea.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Carousel/Carousel.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Carousel/Carousel.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Carousel/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Carousel/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/CodeButton/CodeButton.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/CodeButton/CodeButton.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/CodeButton/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/CodeButton/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/EmptyPrompt/EmptyPrompt.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/EmptyPrompt/EmptyPrompt.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/EmptyPrompt/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/EmptyPrompt/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Group/Group.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Group/Group.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Group/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Group/styles.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Image/Image.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Image/Image.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/Image/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/InternalLink/InternalLink.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/InternalLink/InternalLink.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/InternalLink/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/InternalLink/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/InternalPage/InternalPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/InternalPage/InternalPage.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/InternalPage/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/InternalPage/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/InternalPage/styles.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/LazyCodeButton/LazyCodeButton.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/LazyCodeButton/LazyCodeButton.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/LazyCodeButton/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/LazyInternalPage/LazyInternalPage.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/LazyInternalPage/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/PlainText/PlainText.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/PlainText/PlainText.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/PlainText/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/components/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementArea/styles.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementAreaWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/EnablementAreaWrapper.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/enablament-area/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/module-not-loaded/ModuleNotLoaded.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/module-not-loaded/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/module-not-loaded/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/WBResults.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-results/WBResults/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-results/WBResultsWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-results/WBResultsWrapper.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-results/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-view/WBView/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-view/WBView/styles.module.scss create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx create mode 100644 redisinsight/ui/src/pages/workbench/components/wb-view/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/constants.ts create mode 100644 redisinsight/ui/src/pages/workbench/contexts/enablementAreaContext.tsx create mode 100644 redisinsight/ui/src/pages/workbench/index.ts create mode 100644 redisinsight/ui/src/pages/workbench/interfaces.ts create mode 100644 redisinsight/ui/src/plugins/pluginEvents.ts create mode 100644 redisinsight/ui/src/plugins/pluginImport.ts create mode 100644 redisinsight/ui/src/resourses/en-EN.ts create mode 100644 redisinsight/ui/src/services/PluginAPI.ts create mode 100644 redisinsight/ui/src/services/apiService.ts create mode 100644 redisinsight/ui/src/services/hooks.ts create mode 100644 redisinsight/ui/src/services/index.ts create mode 100644 redisinsight/ui/src/services/queryHistory.ts create mode 100644 redisinsight/ui/src/services/resourcesService.ts create mode 100644 redisinsight/ui/src/services/routing.ts create mode 100644 redisinsight/ui/src/services/storage.ts create mode 100644 redisinsight/ui/src/services/tests/queryHistory.spec.ts create mode 100644 redisinsight/ui/src/services/theme.ts create mode 100644 redisinsight/ui/src/setup-tests.ts create mode 100644 redisinsight/ui/src/slices/app/context.ts create mode 100644 redisinsight/ui/src/slices/app/info.ts create mode 100644 redisinsight/ui/src/slices/app/notifications.ts create mode 100644 redisinsight/ui/src/slices/app/plugins.ts create mode 100644 redisinsight/ui/src/slices/app/redis-commands.ts create mode 100644 redisinsight/ui/src/slices/caCerts.ts create mode 100644 redisinsight/ui/src/slices/cli/cli-output.ts create mode 100644 redisinsight/ui/src/slices/cli/cli-settings.ts create mode 100644 redisinsight/ui/src/slices/clientCerts.ts create mode 100644 redisinsight/ui/src/slices/cloud.ts create mode 100644 redisinsight/ui/src/slices/cluster.ts create mode 100644 redisinsight/ui/src/slices/hash.ts create mode 100644 redisinsight/ui/src/slices/instances.ts create mode 100644 redisinsight/ui/src/slices/interfaces/app.ts create mode 100644 redisinsight/ui/src/slices/interfaces/cli.ts create mode 100644 redisinsight/ui/src/slices/interfaces/hash.ts create mode 100644 redisinsight/ui/src/slices/interfaces/index.ts create mode 100644 redisinsight/ui/src/slices/interfaces/instances.ts create mode 100644 redisinsight/ui/src/slices/interfaces/list.ts create mode 100644 redisinsight/ui/src/slices/interfaces/user.ts create mode 100644 redisinsight/ui/src/slices/interfaces/workbench.ts create mode 100644 redisinsight/ui/src/slices/interfaces/zset.ts create mode 100644 redisinsight/ui/src/slices/keys.ts create mode 100644 redisinsight/ui/src/slices/list.ts create mode 100644 redisinsight/ui/src/slices/rejson.ts create mode 100644 redisinsight/ui/src/slices/sentinel.ts create mode 100644 redisinsight/ui/src/slices/set.ts create mode 100644 redisinsight/ui/src/slices/store.ts create mode 100644 redisinsight/ui/src/slices/string.ts create mode 100644 redisinsight/ui/src/slices/tests/app/context.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/app/info.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/app/notifications.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/app/redis-commands.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/caCerts.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/cli/cli-output.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/cli/cli-settings.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/clientCerts.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/cloud.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/cluster.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/hash.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/instances.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/keys.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/list.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/rejson.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/sentinel.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/set.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/string.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/user/settings.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/workbench/wb-enablement-area.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/workbench/wb-settings.spec.ts create mode 100644 redisinsight/ui/src/slices/tests/zset.spec.ts create mode 100644 redisinsight/ui/src/slices/user/user-settings.ts create mode 100644 redisinsight/ui/src/slices/workbench/wb-enablement-area.ts create mode 100644 redisinsight/ui/src/slices/workbench/wb-results.ts create mode 100644 redisinsight/ui/src/slices/workbench/wb-settings.ts create mode 100644 redisinsight/ui/src/slices/zset.ts create mode 100644 redisinsight/ui/src/styles/base/_base.scss create mode 100644 redisinsight/ui/src/styles/base/_flex_groups.scss create mode 100644 redisinsight/ui/src/styles/base/_fonts.scss create mode 100644 redisinsight/ui/src/styles/base/_functions.scss create mode 100644 redisinsight/ui/src/styles/base/_functions_electron.scss create mode 100644 redisinsight/ui/src/styles/base/_helpers.scss create mode 100644 redisinsight/ui/src/styles/base/_inputs.scss create mode 100644 redisinsight/ui/src/styles/base/_links.scss create mode 100644 redisinsight/ui/src/styles/base/_monaco.scss create mode 100644 redisinsight/ui/src/styles/base/_overrides.scss create mode 100644 redisinsight/ui/src/styles/base/_react_virtualized.scss create mode 100644 redisinsight/ui/src/styles/base/_selects.scss create mode 100644 redisinsight/ui/src/styles/base/_typography.scss create mode 100644 redisinsight/ui/src/styles/components/_accordion.scss create mode 100644 redisinsight/ui/src/styles/components/_badge.scss create mode 100644 redisinsight/ui/src/styles/components/_buttons.scss create mode 100644 redisinsight/ui/src/styles/components/_components.scss create mode 100644 redisinsight/ui/src/styles/components/_database.scss create mode 100644 redisinsight/ui/src/styles/components/_forms.scss create mode 100644 redisinsight/ui/src/styles/components/_popover.scss create mode 100644 redisinsight/ui/src/styles/components/_radio.scss create mode 100644 redisinsight/ui/src/styles/components/_resizable_container.scss create mode 100644 redisinsight/ui/src/styles/components/_switch.scss create mode 100644 redisinsight/ui/src/styles/components/_table.scss create mode 100644 redisinsight/ui/src/styles/components/_textarea.scss create mode 100644 redisinsight/ui/src/styles/components/_toasts.scss create mode 100644 redisinsight/ui/src/styles/components/_tool_tip.scss create mode 100644 redisinsight/ui/src/styles/main.scss create mode 100644 redisinsight/ui/src/styles/main_plugin.scss create mode 100644 redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss create mode 100644 redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss create mode 100644 redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss create mode 100644 redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss create mode 100644 redisinsight/ui/src/telemetry/analytics.d.ts create mode 100644 redisinsight/ui/src/telemetry/events.ts create mode 100644 redisinsight/ui/src/telemetry/index.ts create mode 100644 redisinsight/ui/src/telemetry/interfaces.ts create mode 100644 redisinsight/ui/src/telemetry/loadSegmentAnalytics.ts create mode 100644 redisinsight/ui/src/telemetry/pageViews.ts create mode 100644 redisinsight/ui/src/telemetry/segment.ts create mode 100644 redisinsight/ui/src/telemetry/telemetryUtils.ts create mode 100644 redisinsight/ui/src/telemetry/tests/loadSegmentAnalytics.spec.ts create mode 100644 redisinsight/ui/src/utils/apiResponse.ts create mode 100644 redisinsight/ui/src/utils/cli.tsx create mode 100644 redisinsight/ui/src/utils/commands.ts create mode 100644 redisinsight/ui/src/utils/common.ts create mode 100644 redisinsight/ui/src/utils/compareConsents.ts create mode 100644 redisinsight/ui/src/utils/compareVersions.ts create mode 100644 redisinsight/ui/src/utils/errors.ts create mode 100644 redisinsight/ui/src/utils/formatBytes.ts create mode 100644 redisinsight/ui/src/utils/getUrlInstance.ts create mode 100644 redisinsight/ui/src/utils/handleBrowsers.ts create mode 100644 redisinsight/ui/src/utils/handlePasteHostName.ts create mode 100644 redisinsight/ui/src/utils/handlePlatforms.ts create mode 100644 redisinsight/ui/src/utils/index.ts create mode 100644 redisinsight/ui/src/utils/instanceModules.ts create mode 100644 redisinsight/ui/src/utils/instanceOptions.ts create mode 100644 redisinsight/ui/src/utils/longNames.ts create mode 100644 redisinsight/ui/src/utils/monaco.ts create mode 100644 redisinsight/ui/src/utils/monacoRedisComplitionProvider.ts create mode 100644 redisinsight/ui/src/utils/monacoRedisMonarchTokensProvider.ts create mode 100644 redisinsight/ui/src/utils/numbers.ts create mode 100644 redisinsight/ui/src/utils/parseResponse.ts create mode 100644 redisinsight/ui/src/utils/plugins.ts create mode 100644 redisinsight/ui/src/utils/removeEmpty.ts create mode 100644 redisinsight/ui/src/utils/replaceSpaces.ts create mode 100644 redisinsight/ui/src/utils/routerWithSubRoutes.tsx create mode 100644 redisinsight/ui/src/utils/setFavicon.ts create mode 100644 redisinsight/ui/src/utils/setPageTitle.ts create mode 100644 redisinsight/ui/src/utils/statuses.ts create mode 100644 redisinsight/ui/src/utils/test-utils.tsx create mode 100644 redisinsight/ui/src/utils/tests/apiResponse.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/commands.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/compareConsents.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/compareVersions.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/formatTypes.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/handlePlatform.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/instanceOptions.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/longNames.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/monaco.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/parseResponse.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/removeEmpty.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/replaceSpaces.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/routerWithSubRoutes.spec.tsx create mode 100644 redisinsight/ui/src/utils/tests/truncateNumber.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/truncateTTL.spec.ts create mode 100644 redisinsight/ui/src/utils/tests/validations.spec.ts create mode 100644 redisinsight/ui/src/utils/trancateNumber.ts create mode 100644 redisinsight/ui/src/utils/truncateTTL.ts create mode 100644 redisinsight/ui/src/utils/types.ts create mode 100644 redisinsight/ui/src/utils/validations.ts create mode 100644 redisinsight/ui/src/utils/workbench.ts create mode 100644 redisinsight/yarn.lock create mode 100644 resources/entitlements.mac.plist create mode 100644 resources/icon-tray-colored.png create mode 100644 resources/icon-tray-white.png create mode 100644 resources/icon.icns create mode 100644 resources/icon.ico create mode 100644 resources/icon.png create mode 100644 resources/icon.svg create mode 100644 resources/icon_default.icns create mode 100644 resources/icon_default.ico create mode 100644 resources/icon_default.png create mode 100644 resources/icons/128x128.png create mode 100644 resources/icons/16x16.png create mode 100644 resources/icons/24x24.png create mode 100644 resources/icons/256x256.png create mode 100644 resources/icons/32x32.png create mode 100644 resources/icons/48x48.png create mode 100644 resources/icons/512x215.png create mode 100644 resources/icons/64x64.png create mode 100644 resources/icons/96x96.png create mode 100644 resources/icons_default/1024x1024.png create mode 100644 resources/icons_default/128x128.png create mode 100644 resources/icons_default/16x16.png create mode 100644 resources/icons_default/24x24.png create mode 100644 resources/icons_default/256x256.png create mode 100644 resources/icons_default/32x32.png create mode 100644 resources/icons_default/48x48.png create mode 100644 resources/icons_default/512x512.png create mode 100644 resources/icons_default/64x64.png create mode 100644 resources/icons_default/96x96.png create mode 100644 resources/resources.d.ts create mode 100644 scripts/.eslintrc create mode 100644 scripts/BabelRegister.js create mode 100644 scripts/DeleteDistWeb.js create mode 100644 scripts/DeleteSourceMaps.js create mode 100644 scripts/build-statics.cmd create mode 100644 scripts/build-statics.sh create mode 100644 tests/e2e/.dockerignore create mode 100644 tests/e2e/.env create mode 100644 tests/e2e/.eslintignore create mode 100644 tests/e2e/.eslintrc create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/docker-compose.yml create mode 100644 tests/e2e/docker.docker-compose.yml create mode 100644 tests/e2e/e2e.Dockerfile create mode 100644 tests/e2e/helpers/common.ts create mode 100644 tests/e2e/helpers/conf.ts create mode 100644 tests/e2e/helpers/constants.ts create mode 100644 tests/e2e/helpers/database.ts create mode 100644 tests/e2e/helpers/helpers.ts create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/pageObjects/add-redis-database-page.ts create mode 100644 tests/e2e/pageObjects/auto-discover-redis-enterprise-databases.ts create mode 100644 tests/e2e/pageObjects/browser-page.ts create mode 100644 tests/e2e/pageObjects/cli-page.ts create mode 100644 tests/e2e/pageObjects/database-overview-page.ts create mode 100644 tests/e2e/pageObjects/index.ts create mode 100644 tests/e2e/pageObjects/my-redis-databases-page.ts create mode 100644 tests/e2e/pageObjects/sentinel/adding-master-groups-result-page.ts create mode 100644 tests/e2e/pageObjects/sentinel/discovered-sentinel-master-groups-page.ts create mode 100644 tests/e2e/pageObjects/settings-page.ts create mode 100644 tests/e2e/pageObjects/user-agreement-page.ts create mode 100644 tests/e2e/pageObjects/workbench-page.ts create mode 100644 tests/e2e/rte/oss-sentinel/Dockerfile create mode 100644 tests/e2e/rte/oss-sentinel/entrypoint.sh create mode 100644 tests/e2e/rte/oss-sentinel/sentinel.conf create mode 100644 tests/e2e/rte/redis-enterprise/Dockerfile create mode 100644 tests/e2e/rte/redis-enterprise/cert.pem create mode 100644 tests/e2e/rte/redis-enterprise/create_dbs.py create mode 100644 tests/e2e/rte/redis-enterprise/run_re_and_create_db.sh create mode 100644 tests/e2e/tests/critical-path/browser/context.e2e.ts create mode 100644 tests/e2e/tests/critical-path/browser/database-overview.e2e.ts create mode 100644 tests/e2e/tests/critical-path/browser/filtering.e2e.ts create mode 100644 tests/e2e/tests/critical-path/browser/hash-field.e2e.ts create mode 100644 tests/e2e/tests/critical-path/browser/json-key.e2e.ts create mode 100644 tests/e2e/tests/critical-path/browser/large-data.e2e.ts create mode 100644 tests/e2e/tests/critical-path/browser/list-key.e2e.ts create mode 100644 tests/e2e/tests/critical-path/browser/scan-keys.e2e.ts create mode 100644 tests/e2e/tests/critical-path/browser/set-key.e2e.ts create mode 100644 tests/e2e/tests/critical-path/browser/zset-key.e2e.ts create mode 100644 tests/e2e/tests/critical-path/cli/cli-command-helper.e2e.ts create mode 100644 tests/e2e/tests/critical-path/cli/cli-critical.e2e.ts create mode 100644 tests/e2e/tests/critical-path/database/connecting-to-the-db.e2e.ts create mode 100644 tests/e2e/tests/critical-path/database/verify-agreements.e2e.ts create mode 100644 tests/e2e/tests/critical-path/settings/settings.e2e.ts create mode 100644 tests/e2e/tests/critical-path/workbench/autocomplete.e2e.ts create mode 100644 tests/e2e/tests/critical-path/workbench/command-results.e2e.ts create mode 100644 tests/e2e/tests/critical-path/workbench/context.e2e.ts create mode 100644 tests/e2e/tests/critical-path/workbench/default-scripts-area.e2e.ts create mode 100644 tests/e2e/tests/critical-path/workbench/index-schema.e2e.ts create mode 100644 tests/e2e/tests/critical-path/workbench/json-workbench.e2e.ts create mode 100644 tests/e2e/tests/critical-path/workbench/redisearch-module-not-available.e2e.ts create mode 100644 tests/e2e/tests/critical-path/workbench/scripting-area.e2e.ts create mode 100644 tests/e2e/tests/regression/browser/context.e2e.ts create mode 100644 tests/e2e/tests/regression/browser/database-overview.e2e.ts create mode 100644 tests/e2e/tests/regression/browser/filtering-iteratively.e2e.ts create mode 100644 tests/e2e/tests/regression/browser/filtering.e2e.ts create mode 100644 tests/e2e/tests/regression/browser/last-refresh.e2e.ts create mode 100644 tests/e2e/tests/regression/workbench/autocomplete.e2e.ts create mode 100644 tests/e2e/tests/regression/workbench/context.e2e.ts create mode 100644 tests/e2e/tests/regression/workbench/default-scripts-area.e2e.ts create mode 100644 tests/e2e/tests/regression/workbench/redisearch-module-not-available.e2e.ts create mode 100644 tests/e2e/tests/regression/workbench/scripting-area.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/add-keys.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/edit-key-name.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/edit-key-value.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/filtering.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/hash-field.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/json-key.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/list-key.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/list-of-keys-verifications.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/set-key.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/set-ttl-for-key.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/verify-key-details.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/verify-keys-refresh.e2e.ts create mode 100644 tests/e2e/tests/smoke/browser/zset-key.e2e.ts create mode 100644 tests/e2e/tests/smoke/cli/cli-command-helper.e2e.ts create mode 100644 tests/e2e/tests/smoke/cli/cli.e2e.ts create mode 100644 tests/e2e/tests/smoke/database/add-db-from-welcome-page.e2e.ts create mode 100644 tests/e2e/tests/smoke/database/add-sentinel-db.e2e.ts create mode 100644 tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts create mode 100644 tests/e2e/tests/smoke/database/connecting-to-the-db.e2e.ts create mode 100644 tests/e2e/tests/smoke/database/delete-the-db.e2e.ts create mode 100644 tests/e2e/tests/smoke/database/edit-db.e2e.ts create mode 100644 tests/e2e/tests/smoke/workbench/json-workbench.e2e.ts create mode 100644 tests/e2e/tests/smoke/workbench/scripting-area.e2e.ts create mode 100755 tests/e2e/wait-for-it.sh create mode 100644 tests/e2e/yarn.lock create mode 100644 tsconfig.json create mode 100644 yarn.lock 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-----\n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\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----- +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 +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-----\n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\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----- +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 +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----- +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 +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 0000000000000000000000000000000000000000..d8b2d6d1c1dac1b5aa138058561a4c9501697c7c GIT binary patch literal 1086 zcmcJNOKa3n6vxl_0Bxlm1Y5yPC-0<9XNE!<7y2dK2tL4_A3|hY_#xC)Q*{)qlh#M^ zQ7yJr5mZFbg{_ZGsgFT-77?$%n@NTmDlVim{Bv$j&hLNDy@^PHe{WH^4v4o!q)$XV z)Qbj?&g2g`Bg+$u=wQi=?v|_z z-wnH=xL-ym+WGM=V#gz0;~O1D4`5a;|C;hD50xF!bjQub*1~~*e3U1QB||4 z_RwP?Vd(8Az2}wbPUP4KHtMj3eOX`}_V&TAJZ9cGWx36yw_#uUgSCU{*z%@YhIO&R zIWq5rn9h|VY=ha`^KwSP$W!HpGqBlNMJ?tzJv932xHEWbhZD|ZXBFVXSxv({Q(^6m z3irN+y`Qku#_YYt9MzIBCSh$cdCitTMepV=zxw1H%t}~Wf?Y&QZPpn3Lcdc(qpy!S zgSWfg@LuQhXueeVypzhl<*n2Y!`0CX^dlH~uFv`tGnMtbB-i2c3;irrKk*1&XW8E& vX6|C|`SNw|*LW1N^}2=skyO7#ex4Lr-7oUKN2I>#FVfG@EOp)cya-3gBK z$4@_`?sD!q?|GN!UCz0;`-X=4oO#ZrPQ7z#$JVXxwF`@*j`N3CInF!7orB%mhIb9W z$EjOzlH)ilw)OW7CSM)d?Sw8m&T(e$*fu!4<-@Q2aDx+i{>P59@MC?0Yu25Ac>C#& z6IzS*=fx*tlTUu&t(zR@zixA!@b>X!EOFSI_I(`p{t5qW8pnmEXu~sT-+=Su#wQ9F zZu!^DC!D&*g5xwlI1#&WvhL)5+zPG0KjBPlBKg_pZo3ob?{}QvET7Eg3okzEY;m0b zegXGgC&Z>s^wzP`E6zRab$@i~>z~6F=gH}ho-MyWx#>ms#Z?Wf8#-`a@6_=s{)gx4 zSH2i^9&H%LeYat?=}GRHf9o)Qzf*TIPU=IY&?=|VX{h^$x-)Qoy8MRDakhkTzb?{H zUtd=juB*Ssan_vbG@NNVz&{;*J9gm5E!BBrbvRGp{DZt-7peO;??DpyH}uZ(_{(w5 zS@GZb&T{X#&S`Sic*ph5QO+jsxWU(^(K*@a^{$6;{c`WP$?0@HZJTfE~Y=UshgdB@GpYx@4p zJ8p4~>tEm%8N6&Z+$g?|A-ISNDI;J6_;y`L*`>GH1cB|K7WPxYPRUD?752 zdvmF=@q)XeW97QF?I*e&*@=l{E}n{|-J!jc$+m5|*yMO>kK4mm1Ie+ebS&52wsvj% zX3BZ9cYC$Dy)&81r?MGW?)MMkpndarwh+%|rg^dLq?0yJ#P%e!h0(TjYGhs8#J-Q^u)xV`gTiEv`@Hy&-tNqJMX5HdAm1v!ewN zk#xH<33oW3%zJQLvBZO zplj=JPtR^QhUaIJn7bhiCx!7?!QGpka`z;Yli*4mVsl5cId^m_R~W}Yx#TE#l!+(X zT-O~M2kYjbVFey>mzQZ;?k?{d?uo9#uSYKreYY`=-mx=TzS1=UN+-vlDzWkeN0Nn^ zWHMu@IvL;q3z*TN+jocV4w7FYysYKG~A2>UeN#yprE8^L7IvFpd zrjzNttK8)k5&$_IZGgZHqDhKs5%4b=r0!faJityYOv6b%3xThh@qa7Vx{X=d~cSlz* z%p$sNpsS1402M^vD@LGYqoPZ&t62PksT6-ncs!n*ER?xzjSu)^v}=+=?@Z<6?5Hy} zmMTC`cS5hSyt)T~*SX5=+}R1XbELSBs_Z1lg#p5j?BLj9wCz589fW zA1CLa;fdrZY0-cE-z_zH=K#7Dqmdqq`G(poPeadJo4ng853(mry z!e$L%7$aOo9gj^;rc-h9mHNmCFjK%xJke_NwnJcwOl=|wKQ}9*={q3ZoEx9Y7qW1w zd)-MNw+S|e+at>DGH;=Kz@7_@@quT=39EeQ1Ja}T1o2jRe5U3bf-r0<162aLd-kQl zMgp!mml~NOgmK!0TgWlMX>btC4~l3MmS*_mVO%xpqBEi#H8Go^4@M`sB>E;3ygN#T z8P#AKZ*-L70=M4-JwB6b!^Z8b@F}2yY~r&d!vPixJIKCJ%I{ij#SwI^-3Mj3k!jzDke}$m`>si6ha*yNi|JY6N=?osf&TP!6BSdYvXOEO6o&&x`}66_NnQ9Nhp8(%HR9dQna=kwh$%AZltL zoz3O(m`I>f(Zq>@GaQc4A{xMvz`yB~iO6v@3I`4ohC43@qZo~hl~^>;gft>%Fw-}} zjHhylcM;Md;1^nd}XC zSxAMCNwkuN+K)h!+!bhsJ9*?$u$_^7vY`D38zOE70&h*n7|;UG%3jD0f6g zvx&UnhH>Q3StitFm+Vo-$sx=ck7dTh&{HT7)*dYkeo#P zZ;Ga-(pa`oAH-6HyrDa$pxx~o+c%r!@lcu9;I{ct$Ls2hU2h443tIV?CVc}AtE-V-^P@{}d!$WBj&|?QjR)p%5Nm8Wx&vpop zf$qG_6VVhi;c{KOx)2h~#ZBufUmSdj2}ik-&qz|B#OD*qEC5Ggysa9-4~>&X+gXto zpkc0wdO5WvI3LJfA}Qc3aw>LZ;0zzG2?SJ=1I-3>~XHNu|^k$(UpA| zP+`xu<_5AA29*J5z{?}5qyEbyCdGy^)JQ-9IBm%`NvaE`3)`i1 zHZxB1V{VP}YObs*#{#oIK4BIp$73)~DTUWYC;DT63p5T;Ho;u&M8EjFl;F@2gbz?l zO>9CxF*}NomttczR(%YjVkIYrs2|P-wyJf*fuzJwjDf0I1C@g6OOPn-Bx@lb(Wl%X zFhFtfe10$1N#WrFa<>XwQT`Wezygu+{t*2r(30Zzr8zS>)^ee><;A@UJ7kIv4S@P( zr4gbWjEFqHhNMZeVI|DV48-aKb5Bbuxv=3X!bU6Xt(>P3ze5R2xf#6Wpi z7mqp|@)RHt(kkFQmVo2saAyvi@$+s79R{1Kb|55zKnzNGz66BWIN7LVjp&-(u(@5Z zYt96U%-Sxsg0*UJ*=UwTxa6ej6B-NC&=eLRVFlU-B5T7dMgS(bHo%TEJQ^tHF{&fy zkO8Jp#1qUkv8N5GcO#J0sqeuGAmPk7^h_R=|5;~?q1bb9p3rWcCkF=gtl4IeoTEAB zYs+HG$59H#k|1a0B@5-rn0Z*MmW-jsXy_A;0cli?d#031TI@vKlf;>GvfQ&^aAigN zN<;?nJ*%fC1r1pc0G{Nk>FwA;Wvl^2?#TkZ(hzQHO zX_sr!hEGNk#30X<8O38I&eRb=2l-nL-X*i0z~T;Z3H^vylwd@BzUlpav}&b*Ad2d`yV;fMA|> zNK1i?d^IYwx@2g>YaRX&`53@20%C z;bp|Y0Ewzp?G91PB-F+fvFfE7P1t7W)8_VO3rq-D@+H1|bsH1mmuWPGGw7DAAR*Xu zmm9LzV3w>zEO+}mx0Z!M)^cW}sT^#CW`Jg>ld@1cfkmz?N|&%H7UQ_0Ngf4|5KmJk z72F4#Jyt=@+SiKdR0RecjbjIOV)m&sAx?vC(v#`OiO}!yQgY5w7{1kUQ z-~uIMp~gTcp_utHZ{-nWuzz6yB46E=CgcfioPRDQnGee_T&FO^fV)XR144bMiq~U6 z4X8KRszEnv!me@#%lUCH2=p5YYjDG?b;OZ%iAyu#2aC#3+@N2_SQ=;hv5B8k^y>tbIqF=I{|z%7Gxlnrc@5b@KCwL2MIAY zoAvf61H?SG^{@$5VGs5w;MmmmAtJM0h+AwY=IIYO4UU6Y36`)b&V@{~&KR3SOkpe? zDIi!3xPU!CVNmbRBw5N&a97zRmO^ou%0mReZ=RTN)_OZr;fWz+p5Vr$tjVK{_cnTi z>Aie^6VWck2N`9rLZ~|prsR8j)85f8#Yq0dLaIyKRQLL$(h#R#RQ&OiLLuMxboW`aJ zQywLS)Fm&jq#jv&WZedFk4Xr*OjBM}!~9TQnZRZ`XqO>@{TOei9Z`CJ9dRD>3@%qn z+EmX}eOcH+Tw)l3xziiCnj-)UuVGvz8>z@V~O@dJ`v{2L6_N=>_uy&h&N?T6jMZF zQ|1Sfl($ZK@h0xlK#Vz-*Dt(j2W=EBrEJ5lS)ed`QN8EO1jdonp)jOe<~CrL3#naZ zjgpl4Mxt?9r8l%v%eX^`ZRra#Yh8i55m$Fw;&gcfOToaKqx{m4Nv2QS%? zNy%8I4ZJykajq>G!n3OE#`KTQLAo#GE-$@HfNs1Gb<11t(jm?2Sgnu;Q>p|{IxQyZ zn4ngq4te#=^%7g+xQWwHit$3{ieZf8Ai8dUbVbH75gkD-m*a~9vR-G*##pB*-}xj_ z4o<|VygRyU72MK2gzzue3Cn!TR-ni#O3`Z$lghoG(raY$lv-qiQWdnDUCZv()VL zD4W_WYaO!?3>9g|wlFbnpi7r6=XA;@T##$9ThyGwtVs`NN7cfHdw@6JqCvWWC26DM zTpkLRicH@$Z0VrE zH%PqwTM4<(JqRPYEXKzgAE?jEj*kh{?F`tUHNsWQz*94vW0M(FuKauoB^Pe*HH&ZH zif%EcwqsMK*+7Dh{m#eB?mZTI5Dq%zWgBQRpAEt`%**ru>0tgo0hFU;vYyst{RMmR zI%l7$QB>iviN@MHZ9Rs!l^}lXII6&nY684%nz9j(B!ChKO%xD>RjizqgT@-O$%qjk zd4R=Xy=yF4ey>2KPckt8_ZgZBjJMcGR-&SqgP-r{A1*78$4k~tO>1O0Ik-33TA=M)C7Fz&({0DbrLl}I6*EtvQOuA$ogOrc} zI>DVgB!Gm9w22D&)T41Iu?`UZ{Fsi(~1Q@4jBE=nH#h5CITn%GZW|k~4 zYt>uUQK%uPx+KBq6(_G<=_X=(x#+4187f&&0U^1DsE53AB408vbfOZOj-@7iJ}-$X z4|D)7cq&&5sJx7_Y{e#0mEerr7q3bVvceR2S&-5&yfnH+AC65C-d@fuidd{+$oq55 z^;a_(L^+R@O}>Nyb?`2#hIua`&QhDed$|x8k=zVtdd{1*PHw5|iqVxhi?9pYL$`?stB8e*fpmhWFy3XiFLOO_7X*;_IJ9?tsJCHUajYLW~ z5bYg8&b3P0Fe7xkcl7skcXi?o)!vStVJ4%i+%3aHE_3Ui?j7Asdx!c~NoFT)eSMh} zv1_1XJGzc;>F((s+HHE>+C9_@Dojclb^D_OL){&C|82naa!aT;UZKW=-O(O*Xm@`X z-UUX&$w56rF<^AajM~@VH6Xb(^3m$7byqiLx24M+?lp<$z;HiFMk?DiFwi%!dMnYe(GF8$KxRJVt|)Wf?d{su z)4i<=qpf5&ri)$OgO~twbM`ve-$kC`-NinGPph%Weors{s4rHG@Y_4vyWwqD71!^jo}^;Dr`puLJxy1?AqDY>vnIY z^t!h~m(Y_N?c7PG@qxkNj_ocA3WMFEAmSH1brtVpAkA14p*{!eEEiM-gMGV{(y7=; zDouYtD=-_L@EVxD(`btV#@f`$GQ~tu&G>p)RuDPMb|x=cV0AQDeUi>AjS%%TAZ7(h z3PA`=%%CK<<9E^32^M;nJiN}cw$h1PECjetoCYL=M- zZ&^)eMgB*xgH1!CwLQ^Y?rNiAct3vtYSP=YyA4?!BHCaP*?VCZUIuSO8z__QapJda zlud$>2$(`;3^i|{V4hV`37RlD6AL1KO2oL28-t|D~B!2b7b z!~uZ&90hNO=eRTm+wq`I5e6penV37!y=^DH}PL|80 zKnA4g0?-*Z*%{q!I|Nd7_jO|J6Q++hy73=C#3C;Tgxz*_vodJPYa|N=woW&?6-6_H zZU^r?P(?Sc1#2U$djtNld?+s0XIKnEYB3JEB(owg3I!YMgBS>yMAO9Bc*B9)FITMh zLIM->dmBV5gGetj@rO{>GUdwxkSM66rszT^kIlj;b)+{rg9Sjou0Py1*cNSr^?TxF z?blSD>9<*(L5+n)e+C5HEJiTEhC7BkUJ!&k=-YgKz})l}nY^5f(>9x<;ExgI8uK$G znJAMh=|Au@<)mqhOWn3dq7{SUWV88Z7L?(hw43>Bj&L7eWMdu=?b^ih~P5I&v=7 z%cj@`vJ>w+8|B`0;U71qvv$TmqazhwY1iht2L6vjK*4 zjmy3ekww`nvc8PPRxn9fXvQ@c^lKj#kZ*)Z3O(mAMID5GFV;r9J%&S?{HBZ-}`R7B?P^pwRt*Cu|uClo6PD4QhF|n`< zVhuJXeMYfSlYnhw{?gFwi6V?~5iy=LYldE`6~l{sTv0<`tRl&42<1X6sx+Ad`DXH3 zm^ybI1yZjb<}%t5F&f_>y2%DwNURxDm7O|xk`tew@eFYggr^;2I|qVnXVCijbUKR z>#)73QoF6u1K_!PUOJP#I^dR(Gg&0N9}MC^i9iSzcx( z9oVii1Xh%v_gH|9fB3KjmTpY;O}*e|MZ;8b4x20>PMV^p%)yOl$QMH^l;VK-wk*%{Bb ziV-A$O1RKUo|uISz8D9EmCX#sXF~~yCsa%>sd;CGWCEj*5GFh1m>HJE*<{3-=r z5{C;$rxc+UoecWwi%{5T(QUuj|MUIMpm(JmeT<_;0kpB*bh%}la}xf)8I;FBSJJc@1r2HR zID+}LIUDicCT9a?d6Jzy;^1NW^O*#o&Ef}68aD}7i3}3R& zDDKS7%{7=;9P^nmPZgYt%&c6e8#9Z8+PR-zh5l{Jaf~Z;QD)>|W#5!{0g^ifUuIlm zMyix^p)6%`cfx3hePRZ5&VVXX92_feuGv|z4WFNze%IM*WJoO>#L+0IlRlET(uFyY zx5MBybx|}ej^m8E;yO`i8|6W+^D6ss@e#}(JR3!iT{s&s$DKGDa<+q8)T2&lV+Z=( zfqT8UPANrA%dNQDga7ZwHS&<-&!BISyUa<(5~+`)6-V6*>ZkBay-R|_qAP4qYjZKP zteL?mTIKM>I9hQ=q<+*KlYY*v4OjX9A+!;#`|J3y!W+fK7dfl>v>YQW$Lu-VsIv;k zJf|gF{_54*Ps@BNpQ$C25!|O9kdxG6<*mx2Qme^jza{WE z0sS7qf3(=Sb&#!NXz71q5aUx0TOgkl?oj@Uf1s97$JkFg?)ShW@T}MK z7E#0}^cY=b-ykR`Xtx4f5GHIPPFLsv|Z;EpM_WSU4p<$ zvmg^?OP!*Q(F@kr?om)etygJN7Swy%lJ++j55)H=*2VB_#*9Y|={6{#{HGm>hLOM2 z#k@BPuX6@skwOV=Tquwcs*#U&6zp@-XeVi>{wED5DG^GQe9$LmFjgA<(^3U7=v9Qz zq@OV(+H;u$9VzbDm2Rs4ap;!gJEj zITA+&+t@;+sSyo1BXJ`+UCXCbdjWEao`(6t1pZTW6H5EdtcZP_m6c#O&Sl1WeT&Bp z>MOOwPYLy7uR%G^m=>(IAUZ}FQp5O^rF$+-T|Niz7}e1hRObZ4i4U|+u_wWFOUI%5 zaGRgTaos;7_8}1n zpGXtBUylw|qesmw)OJZb zBM#yat%#nBzMVLq!_`XNrj7)4d!$OYtM%6MSI|K{AF-41lf)C2hZgyS4zWG8K7Wiy zsrWfV6e2QN4q2=fRHE#y)`fK;<4D{>^dzvNP2@>;kRVo zqfE$Gt51}JjpGg_)|GjZ)0Ch3W%eunPUlQdEp)3U1fQwoh58=LN3j=aRIkL7DHp5lopYn)XdfE$*48`C$4T36JV6?0TZ|MLj|fHLyDNK?tMs!;6CYbT z=&!Z@7}QSIG~=eVXa=L8yg^RpLiDF`T&dJW?TgwbHaVLwuZ?UXC5yund#jEMh13qI zfJxLzZ%Qt(3PGMotI9cQT&9xISqY--!Sl>sEO%XKHTz?JN$s_j8<`iiPf=&?c+QXD zmHH=E%UP07v^X2P(6*_)tgev`;RRcXwx}Hnu8N10nzcp=))rn`tROtBj0NI`3eH>Y zMR0>Wr%X5p%79WC#Th;Bp>T*iA2QrNbUxb1&T4{ac@XD>1EiDn1&NeMs~}Z%&4GNT z=Ib0atCRLtr&w2(nNDDy^r&JH7L()&jku^~wDnp&PzNT_gHk2&gvi`a%k1?Na?mvu z|JiVh)+_VY%9CIOu`G?baLl+zBeOZNC2AP)LF#a{0O}swSt}#*Xr`zwiHKtN^c%Lq zYU{#kg<@M`u|z?syGR62jTS2dS+y_OU{}So5oSvNL=~E*0cL8wvst!Ke-B9B+8;~pX{Cja%`V@^MEtDOp zkRy?k`d9YBIu%=U436fv0cz3g(j_+WsamqtsKt**Iwxu#E7h!g>1c9|bwTUVsoT_d zBD0OA)N(a$wRwqp^IE+qA1TpcpY*L|GAbYvu)ZsGA6ehw4Ap8St`t5~cLeFQVx;zP zC|0cTn6+PW%2p$3^P=H@d%Gqt1PjRx*IEBJu5WZey4BUHej|0QFjrPOEFUyCBz7>8 zC$?}E#V=>ESzZ?}MeiUIrc`K;#8#~h%pRo&>-mgiDFy32#pZ^M4l0(bl;^SwTPsoQ zB4X;gp~M$rp|j_*B7GgrUt6J7P%9V_N))>S+z~7zCK7GPMINygEr{qGMD4kHl+_hM zvaJ|}b}n^biO*yVe=bDtOceEJ<${Ys8L=&x@riFBR&s5TH3X5fAc5)$txtNW%)Yb+ zMTsc}QPRXv#WjuQgEOB?a>~c*ssJe=($2l6rMM^YU62~`V{>^vqOY8t@F0t$1UP7Y zht0?ZXQ|~x?rPf#@=Uy<@IoRRa+X|D%#>V+et|e-V*q*&<+tD7G}e-}3VA|!O9a^t znz)W7F_+ja^`X+bX44SSGZ};R5Lp=#zOdFr8z2_UHhYmb^IGv{Ds}1CZ;NV8v(HEE zl@Ws9cUe5P9)lEB&vHeX%ap#YMUXF)6RFbJyLP0i9IUS0v8ExBJFQ)Gl~j;YMtLcW zKucFSSNjgN1bQy6LuppYwPV#k{|u>*th$Ich>Z$5=;&gh!dX%kjM8ZjoGs@q7(Y8w zuy=Z4_8=Kz8vO-vaJCuw=~ui6#tr1EKaE2%P~*lZ9JZO%?!kYc-rBRE$p{}K+>szj0F5-r!VJ+Z0donw)CwPSzP z=D$HDTzliLuiDl@O`%SRrKzr0+mRoC zC&!XVka=VrXO$}zlBo%{&Sm%PNjXD4)AB~kSI8?;A$m*6lY0Iu{)W29c@TqYNx7QO z!c|86GDn%E9|V#VV*DntvL6HW&WiFq zwMjt}&XyFC`|Lwz#cW^jM6VGos8fO-dR4e-s~da0UWC)k%`MIyx|F5YYPnSEFA<^N z(CN?pBt`V~QU;h0p_WJbbki|4{=|C+k!gzVGOal|jF z6)+~Ud{--}u3K1Z^Xm_3rWcWTnIj9WL`12GXqACBriMyYJeR8Z zc5opW#VUV;v+e+|1iRa@KbhFg(S=K=n#haRy&6X{zTWfuGl|dqJMwfNnA#T+pB#|P zmv*8p_$)`zntJ6tX}^pH$I*)R#`Seun_W@%I`l&a;~DGPwfjmIHPLnK3dWA*uUFtWqtX>bQtmx>Qv8tgV|^|1(>pNMBr8%L~@lskN@zdp=wA zr)yb63EB#6QoIQ@jukBG1FxzblLNYIQfd~pX3n{=ZXvoa(HrG#_x@{t^oa$`gSM=# z#oq_xWh>!9&=kzr*^;q~m7LACRQH3?yk-xTb}w2h>j;8&+NbXIoNFx3Nh-R`EIA@= znmZ)PL9sp?l?WnM=81MZodczK@F@ImsgoAlEzYXv6seGQ*f=-z(|(Zz_!A#5!|7-CC8}Ol1J-!hbB)MS)ZAmM)G(!M&M3*t$&N33;G(3__fDF zqr&QXrM^|`vPLR80%?t<~b1MIS$&(Gzoy>Z26fB;w&+@ljI06;zYG zOXMMUqf>V@G7FBOHd6~B=U#PDEf9Yq`M=^3lgf|Ci-_}(}!&`T&{(J8VPYF=1^-8L}gkH$CHdn z_M9pQhH+J5%Y)mipps>)d1Xiq9PJ0h{d=8UtWqS|FG{ndFW&lrhR?_JfgGq5quNz{(% zNf;xfK@BZXb1}w?+-Xh@DE|9LVPrzNsb`S&K#kW#AF4A6iE!HRRGZ1$lrldRwW#z{ zMxN68=>2rvTJBpe&(2L!pos9l8J%3#HzBJ!cLsk`qYxds{b!y5FJ4|M}3j}Qu7f?p1q!%vK%4Mn%mU9~}M`o3~|NXv!(Xc=F7pvzwBDGDeTTrxeHD?AiGe;nm#4{U_ zX-0f7Z>M{I1p@^AXdkvGspUvqCF}WzivOw)s+Hos?5uQ6eKrmdv1x&R{gAwhdd+*Z zY_^!&qiYYGm22`yL08I}{ZLknQp9#t{^EWQq$QKLv9 zb&KfB5#vUiX3Mgwqh3MnT7IV1>H_C7Wa4yxwWPY&O6(An5({BgAyyU4ON1lhgMw?o zqNha3L#thD+~luTD_`eAWR-~^vhLeetY9QSzKSl3wTfr8wRP1=(R+(e(z9RFq*w6) z{%VEvs}`%3Xp6mqA=E3a0ocm5M32;5xhJFA`Zcw~|Gd`flsB9qSI=Z8oM>NF)FVDEyeDmfZ?bYOy^B>a zvZ~xIw%k0)UBU8}AT5*_Nv*I_;u-ClqsSg0UXi}7XRtLaD8-CZEPS|G}m0jF33OG zM=7<5G?Yl7v6Niy2L@4zE^u44+-;1{T zYHdR2N{(5tEVwMYQmoanC;CB^Rqc3LXT&}K#C56K@QC#Ib%c5#?}O?}kj_BmYtJRu z5^D85x*#>POUaM?v)3#n-y}tfT+GR=f1}hW6WW|@qq|gix6($Kp&YzI!WfX8rc9Wh zRC0NC?G#I==az_0e5pi9BZgM$HFu9#PRTRXk`vva_RzA0+ng<_qW@RFq_VHI)^%{L zk$zNHm z(_OW~JIjav4K+f8Sc348Jhgq(f09S!xmb;4hZgPrwpiw`YEz44jh3E5_kD@2lP*%K zwQ*WmudyjzAs`|PzFIU>OR_nRf3C_$yW>o7i`7s1RJA5r-EvrlYSS8XEboMIPXu?r z_T#<&t@y2$q+o=O#fli$lE`KC2BI0VpOlgzYB3k#GhBsHI)ZfiGidAoD#sV;N}hKx z-50#K^Q4^!nsGl7kA^@Iapm9@1D%`BdoG;#YufS|@x8THnPh#g@;ezq3puPhLmBYg zT4JrZgdEYj!C$-5z?CyWQu-hwxYSD|JCHi1P*UrCXsatquh2vfC0Qr6B#2e&6C@^) zZ;dIZ{k}*MlGZN0O&Qx)4DzqAuu?r^?9h5(a&H*3p@oX>4jN13nau9c>GJCp*L8!V z`Kgu)gV?EzqH~kc{B3oH@;giT6I-~Z8AF`+-604oD(|RPY zQoe}4vT?ga)U;jFPDGFvYBRN>8XJX@IzFZA%lZ@}IHHG*`>h7hZwrc$)1*YdT%+9c z^G)@h&r9qsYd%til$a;zJw$iOPw}^cp**Gxtd-4<2IN1#sll($*__9Z-!i)L%3rtO z>~v*b5LQN&xoHhb*Sh>xZ+i(Mzs&`!^L0VX%8Hi7Ta|~-m3QPBy)xSyR(3xa>6e{y z@@BlPo!44v;QakFk^Lr>6pG$+|A|)6^VmCb=ugTVs6WIDT9b@pqgcICT^W;+ELU|G zpFalvYn~qUY=It*5iBbwI(x|>89&qh<+|OsFIL2yU7}ujO8c|Yqz+NLw03AKoHia8 z&hlAWKCyxl@y}K?l;4$N{LeG%(`6M@<~d7)H7m|Z~e$s z#v5VyjJ?on^PS%g^Gi(he|JK61Q2JsjdXQJ{FLfl)2P-jpQ-)*oJ#H=3{w;ns&U2Y&0M@0hW{DSM|GL|r$J~)?-%+yr zois~Oy-}^Io)KH{%X8En*-^yUfZm3j7zS6Wy`?R1I|d|bAHdca&~h+JNFuj$X&Ti+o1;86)VQ4bHCATzx5YHM3p4@ zAt=&|?+*{*%zAW*c&LBM?L&=4lwuiE(1Uo*n1OOdY1-Hvk+h_L8^V`Zp_G>%w!hkHIKPzU4fGXsHjiw_2mf^PQk#CtC5#o`WX0khcbO zY<}I7v+BhEyV27&^u=H9@a6nSGwJU&eef%rmHm-BJ!roTlJmfI*9As3)YEm zKh8#vd~@^Q^bDeht)ONXdgScoEjX^R>9d2l%TaV5!IAk5>;c0InI*r0O+J(R^5yHw z5#-(g+GyK;NO1rX?JLiYG`W~})H%I8uC|`5XQf$xqo6E`9{3IFEr#>_f@wF-?C6}A zXj?BRr`3?lI$Jv$xx+7i^ZTy$>#gjE^4yMI`f#@o66?m5!LruNyKFtM1F^we+4~cb z7*Elc@N4(<$81Htqn-KRDW_#AsteAmKh}DgMh>&(&03RVrGa)UzFBJq%zWryCP4$= ztd;LG(Rau?fnbaMc8OX#eWQ3>rB*9einnaZI8kCjt-LWRuH=Wre!4Ef=d4HYM>UKl zwD-zc2}k`q{FKrll`>Wivl}s7ryS_r<^5;5ORAH=9kd7JxI(j}EMTU%2e>62V5 zR6P-|DO3{8iT$LIzQksr>Lu7hWuh|Dds{JoiGd|=_rHG;l(ubIz1v6V@$a@#s;T+p z7JWYR^rtA0j=KLvUrsFDd`L$RQm;@&MwG%_b|QcW|B1-uybqQL42~T9SUDT)0juL^{+S zi73U!f;-MBizs-(jM?Ts%2oDi-|woNrR|@Q7;Q;Vkceivd=&I?&ZmHGR%o_>;)s=ZM%>N~a8q4eo&X4g65xJo=|M>3AC;;Kbgeut1; zp#@U|bbX8S-VKZ5D!och$54uby_1Rr`rs;(a6o27R8Y@ei#Oz_{|gkt6KXRt(&mwr zKHs@xwZm^!99QHjH45r3ZFdj;l`5`%4^%9SyQ+9Z2?)P*Eaf99w{?OTpW*~YfqFAXB9Wp6O$>=gVki!ZpagI|5WpQ`@>X=3XT(U_x7f9Re=fO2*;4ZKgAxZT7N~boKO!s8!7mxpW<<8cV95d~HR~Z~ z+fQvNCx1z$<+#w!Y=mzK$=@Ieet(NIv=ZZp;_oR{{r;2XB)ywrrJyXm9>*je>$kng zA(eq}O(h{?%a_cgwpKkpt2oqNwFGiQVtTQqN}E%SVn*Pv2Z=Oh%Y0}J`V~`B=ilc^ znrswa9sh7nR+A5I)0PIY9g%-+3#hIo%RHFzs18@>7s`Vm?ow+Nj`=fEm6kloy;7ui7{AoJ zpd{y(4W-BxBEfRYt{G`g8 z#?=ngEQZb9Ao2ufBOcxV9SQMH>^I0^^(HnZmN=Q5mzZ4FmUu>(L%lJbw zb)_B!{ewQG!|W!Sqmbz?Tveh_3V79z5y- zc9Y)OzvE?NV&b6a-0Yc;pUYxl_RV=(C-GJzj9R@g9u!Y*S4So4;#J0j?16rU_#lxd zJ%%8x| ze;LWk`Lyc<$5v@!#3nYrtx@B&{DdvjwWVQ8QbY}&qVD(n#SxLFRiDy@rNUi#J zD=l6XIf-6Ze{)AUU`LYZ&flLzY?Opz4ewe^m@C>-nm8kwlj@oH2+mC2PEbnb@`$s` zn;Jw-)|i9G%wB5u%2;3UmX@vlNp^q`IR%X+-jKCx+6!av1mX^j!Tm9V+ONF(elUcP zKA7>*c0@|L-mH?CeXg?lBbqD7DmKj(Ua>l@Dk(}zOIn4Vs=q zs+*Dv=-*!S(;|2nj5(;egLty?Z}^hi-QacbZ?JTL|CLn)YQBC4S@!Vg-`MDK&OqG8 zm90ViU;ZjBS1bn+&vB)z8*6Zb7)MtlWtDIM=Ug+BbuQ8o?1$~SI!XF=m^CxDl)n$i zbv5=qjB{CIga zYOVxwMUcJk#(kxAtGU88K=Q(_Qbuvyk5&U{&lO+!i?##is(;;*a?-y-EhBQBu?JT< zPCxpUzYEO%?M#Cll^I6ygq>@jd6sjt>(Vka<%V6OtX^x~W#&pMxE{?cgKN&Z;yHkG z{+&u$QO2jjTRn$or_z|}@> zeYctaE_2=9B^O2F~zltEloH|I~LB% z>O3|1&~1QwE65At5qlDp;j!Qnuk&scJmG!;k%o>ih#7EwpL6EyIU7HMP=9v=b(t>& z(bY!N;xQybx4ucEdU`NQkNJ~Cq4ZVs61=K$aP4m?(f4xS09T*s-{|4>3HHmFRNgiA z@0pdDo=-@{P9h2Mbn3AyGgqkPpq4d7#tpOYeezR&Fi$A^ z(r;)7kA-vEQ$?#zFW|w z7j1T%8pj|wLA{=PCo;1TTXT?Xj@4Bgy#?*fpKn{s@aHbVDax1HO8u2cSN3^R!mJSJ zUQyoDN|Q*=)&)ro^CVW;i4v^V($;B@lr8t2P}_pp39sgHReC$Lwo-K!Jta0!d-OdF z$r>1MlOE|?bYJHpe>+?&l2RKtCq& zC}RbU7P<|RTTiGyL1G&2-s0#so1|Ccm0*;`D~!iT6Za5OE7h;lv&c?3jYNXaO3bU? zP5dn*S&hv^SLtPxTe5?V(Qp*=5YMOMaSZyN{~hJjb44QRwAMMbmPCw~9R<3RTq?cP zgBhcDigmR;mR3RzRpY2**78K71V?J+L8G2N*Q#T4R`d&6C0JikB@jFzq6EJna9EL7(=U^27iSK7m(#KvL>Aqhc`z=+fe9SJ{UT}%tRVXB4No$P^i1xDo zm^}q6LGp}@$sYK$jVUxsmla0A6U8-hOvYg(LP`WjxZjO&1Mkb+EthO9lQEAVs3M&5 z?yv8K65yOUOMi`Zwl`R)Pm~8^xmr3TH8P2rbgoLJa8c^Eg7349qFm4?rRFBOp%P-X zKq4gRi#*_(z3^3JqR$_yZZSd^tx`?bIndAO8m01!qmv5pX_O{wEJPBHF8e$5jS#=4 z(*x3*NUcM?DqGCO4@Ne$E3T8#Ml2s>))HCsdL``~&;PXx(J;{~*0N+LpssDneigr* zgE4z8sRzgOBZpAxe>-Wm3KJtbwGy!eM$yuT&{qqYRX^-VB4MukseUSVXp_OWo0O`cWa&o*<*Qda6w!ps zvVy5J2tEZ@Is6({xiS?TRXN2GR3b__d$!*T@!N%!kLHT>R_bFI9g7DOYta7e$hHM% z%w47Qd>K$ceVeO-sIyic1~Uh)k5Uh5l|&H14MAYBN%_8(pqfOoyiV*V22@i>#Ns{w z6TzL6f@z{Pq^=U}g^R4<%Pyaw?a{|cL~QlOA5jVBpO04J!KBVBdZ0E%{?4YW)CBI_ zCB6xQ+VTC*Q@VcbvXYyvXW@(35$8+|lsYr#B=Yz7NZ;*`W!OfLgg9y61|%QE{zT*C zFY+<#;knR7K3Dq4>K(1}mKY_Y`liy<*noOMN_m~*D(0vxxONegcCAs>dr^Zd--LQw zc^B;UbHuitjmMN;H@IZETph9a$DFMnwQSmitT1VGCb*?*G~(^FN40-is7iEpFY1r9 z$v^5FBQIGc=U<)WG5aQ;XbrVBfU;1;AeGd3_NOwb<^Zky;JggR)0`V6&tApn^N8zy z{3=)49FbDdIz=6S20=RYy3)%&?7aQ!Od4MXsSv#9ENxB2M)e27LXCz=NA0ml7ww%| zYc-Ml@>6W3Yq?16J@{;>pZrVI^7W#F(Ob*YbM6{LX$^oe5k0l;XdtyjFl*1MMfUx4 zB9MB?9vo3_5oFN)aI1gYw%OqIKuYxw_n6uR+al0$q)Iu$`H8EnMBJ` zqvB9d3*>Ji(C-O0vq$P6=cIT+S@5pP#Y&L-57j!=E2=jWZ#386T%axbzx@~VnL4`i zTdc+(Yj7sS2VGmIUfD`Csiuz51I_;Kh+hiy-pp`hw$du_ts)x#tW7N`n zowK}6K@?yHW-&%~A(A&jt6wAOG5pme`;?u%?4scFmd?ujmw&g5_DWAL@g=oLpO8pA z4cYj2Sg2$uG0IWu;=D>t*D4cbWurpAE2&jteV#TqX0F?qmNv=jbICWzWl}4$B2PFE zzwZ#wBb=lqu_cjAC8o5f9th9$JyU&7?vgG(FSZiJSkx{`S>M=F?9g60is%)q^eQ9H zQ7wgi>JA{S;@fzUvQq>R%hQOL`2ZzGY+7yF$Qz_uL3^otK!>2CRC;hUMqVkP1=&IbHdo0FW^Ij_g6@#&bs4(Cl+ zk9afmhwoxcIJwZPLtUZcL-EkE(5UnG&ikDAJ0EdA=6uHataF3&Mdz!|?arOfJHNldK6FB8TWCcnVP@Rw9PTW3-hj21bDeiO?{F@6 z-tD~0`3L6%&c~groKHBPbiU-==-lMo?0nsM$a&Ct*!hO@i1TCTU!9*gKMkE6nhu>4 zIyZEBs5jK(6hnnjE;JQNh9*L1LAHCr#=+3;(3zq00v!CK^Cz6|cS@m-P?UEQ1ct zX5CMgzGLZsyDjdK?qYYTyWBnAUF)uQPjNflFCDY|n4x3NS$@Rw?aR+wo;>c6KRr~c zd+|k#<3iJ~ajpX|?{MyS9))DS?>z1N%y|w{`Mq-h+`KaM=encN|2OKMT6*cy-=qJ9 z?osZmT-Wr!&OO=dfAE+$d;KR&|BmS&L+Jlrd{g=8E9Il_^5}oQ_dlPJE6@D-*#&a+ z>??m(diH4iSNF5qe>V8cH=eownNy!x_|q4D`o|yNY+8iCqf0_Zg_c6kH-)xCRzr~c z`JrhX{axsa(8pjyPTeQ#u4n7I8|rSY`gBM~cf(Hi!%}aAjnYE@3>$qAR{Ah3 z^c%3360G#cv{UE5oWHov|X&b7{Q&gYz0LvN3FuE+O$K97C3UvS>yya;>wGBo-YVE0!L zMc(SH2bjJ!6atdph86oefb4ex$@k$KA$L1(3)MN7hU%R+I`_ib?n8!kf2hHEd#Dj> zyx&B{=Q_f|fCg(iZ zW(?Ld;yepa@pHuSzkql7Hz5D7;Isc9yht8aRdD_r@x~vV183m>ahRwJ{5Y8PBQWOQA@=`v{tW1wA6gh%9CG0_+A*0^0slK-z-IzT$DpKpLzjl$ z6Z$~tqoHd;H-^3vx-;}Z=+V#*LQjW&5_%@|i_pJ^ejWN#U0vORy2W+J*R892LtR(h z>2(8jXVs0=P1McQy`}E5x__v9f8EFHuC4n#O#iOB2kRcId!p_~br zaQ*!Hh4rtjck7R@UtPbx{?z)d^*ibZ>vz{D>eKc4`itr>t-rkfz4afe|9JhU>%Ub0 z)%ttuAFlsa{SWGYTK{kLzpnp7eW@YbFt6cd4M#M*yy2LJ;~G{ptZCTP@cM?+8wMNB zZWwErZn(7Jy$v5}xVGWOhC3P_Zum~a(+$6Dc)sB;jV+Cf8jopQ-T1o3j>ewG;l`21 z^BZ%GdmArpe0Spq8n0@+rtu4nw>IA0_+aB>jo)p2y7Ae@|7iSO@hux#Hn(hP+0io4va4mZWxD0vEgx+8bjz1oZg2T|%fl^?w>;VMY|C$3 z{?gjqdRXf#T2E-**!qUnt*t|?=e4F=3$2&5Ue@}))~j1@Y`wMh>#g5x{pZ%FT7TC1 zo7Q6MUn2F9mqlI`d39t>9$gappBpoS4-WItc@{!1ABezEGjyx23Jo3ZHvytZ_ z&qrQ}yf|;mypeg?dDGE}SUi`_L`QPT>0~rJmdzyhM02UkSS&tONOs7-@l-B8H8Gk_ zUf7Yy7Gm*uGE?Y?$M9UI`L|0t=`x*km78=)cU`8n=Zanr&`6Sih;qP%9@}dd#T3_R@xpz2;J%^x9{7?HixMRC7}k z>DW|Zz&75vX}~ldG?xbLP=oPgB9%sK(7dkwBs18ibwj4HT#F7H;)i9JVKdAyIXdhg zYgneU%l5o$EEk(j?lJ@JN~DsxWImPOZSU_k_s=x<&n$Bx+GQ^E<`Z}J^)vBw)W+T)G34JK=^Z<2muGRK&iV@zfg zD=SajkQ28vh?l938-69sze(vNX*x-kn(wvUhBtgi!tm!N37h}#g-gsipG|rn#c{@}-pfV?$ z!8WZgn8rL=*S=-SkUS;BOqpS($j2%FSW_~I8Qb%WQHdEd;EbxwUVDG9xqp$le^HrZ zv81_>wJMV^Dl-l(Nu8fYyG(2{n=j1&`sIe@&OL_mhA@>!=Hj~b zJoME4MBj0TJ>FndynUl>v#$MQ+h&umA30{Xqiy-|Y_2eRu6JMr%7=WDof1uNn~GnQ z%w^jW$qWFNKZO}{Rv6DE&G~3{D#z=iscCbbPhH5zwD1crav4=e>gDx;n(z+h4j%Me%K#>On*2q{qdLm;g|jK$MlB- z=?@2{Km3~h@>3)14?paWKc+t%nEv?7{_xBG_+$FRf%Jz1(;t4l{wAfrN$GD=`R2Qze(+H((7+>d@r;+1HmLS2|yWS&!JVedX(aIObrr>ju6ImUD=Y~LU z9P@zm<$vB@DR-a)mV3|(@$AGz%*-{PoJhs9>1;;M=vXkxn3*XiYG8xxx-g!OO(e22 zWa0%=$$TM|&3LD=$;n*y!sG>0v2+H!TryUGcjLv$^i-Z=pGaloz`HQ1 z7bXcRQ}M2;TsDz~+szrJ!{cc}1=MUWWQZR&;IClMRJ;r;Q*jSVFat(3=u8j=Z8Vg@ zHG@1;alxgjxF2?TH#wHdlja0jLTFhVB^It*yY^(mHwlRh`wDF0yIAA2z2bH|o=BCc znV7=aWO{E~AvRKG8c#$fI4~-VU)lhE@{_T6k_W;x984y24AFSaat$XwvGATJ2_AAX z8#*=`BeQpmjCh+G?&&Jd#Hes!j19j&HOHAsoDY`qfvpK~_%*V^FZsA`{fT6(ktUn# zF+|DJvL@n$hL0b`DIAWbvyht^2Izqc-ehq^#_>uSme|~bE*z3mF!5v@8cUTcbFPec ze3lx@P816%0vZV}fy0II>{K4^kEb32h%Mn<(GrJ>r+9|Sjw_0g-gAN#IPoAAM;2VX zHi|AdRl&;4aKW!$K<%(YtX7ILu! z;iR4nZd?VU^x#>Z!3xxz*9tlOQ5w{>JE_nLXT9qZP%pIF(tO@}aP zwRhrtg_-gjPz?P(3b2{b-Ju`XImlCY){WO)Qum3vo9gbb`*vNizP0}4^#k>z^>44g zvi`I6`|5vD|A&U2hM|V@8vdc-6AhnjxVhnp#>I{2HqIa${7K_;jeltTpYYP~hVUE1 z7lz*wzCCw_Je??G9vUkG1?u%fGce*YdlTVryM%xOIN( z;jOP}jkfM?jkliPnrVGU>w8;2)OuCxr&_OT-Pihf>-Sp!t@U@U2U`CJ*;!L$e&iVB zXD3CvBYlxGBIiaDk@3hy$j?3%`9kEj$Rm+|iTpD1N91KK^A4Z)%6Z4kTRZQK^Lpl; zId61cZr;W7-ahZ0^RArt;dvjMclEr_%=^N;+vhzn@3DFRGVj@W|2glE^ZqiwasK@I z3+FGLzjFSD`EQ)Reg5G5v*yPZ-Pc+?yL5bUed(Oi*`*Uo>r3Yjw3f~)9$(s6Ja6gU z4W&~`3yZHTo)&E_ZZ9?$mlQkj|EAKi(w3#S9lfa7@Y4tW-?htjhPUkPAKS2`^s?>$ zT6EvNZ2!8e!^P-vKPeqia!)9QO6$;N`)}OhYnPS=i;pfUhClp+AN+2~^Z(pmTKN8D z3yR16`j^GV;^H^lRod{`Wu=wZCT@Rv@w4B$w^;Ybht65|$))#(i{Xn;dA#(>C8fn{ z-&m?IH9onkxVEsYc-+)I<0mX$zvZ-2Luuvir+)dir9(44q zUfxt{fA@*Ul{POa^}M=xL~#H!>BVrbD4tpj|8jS!^}3}Gg^MdEONV{Cbo7$a8Kqa3 z+DgfIYiYE2Wa+iVJxkGJ*j>H}4) zN@JiZ4XR!Z$t<|P_`Ptk?%79v{@i_Mmg<+5p1!~MWK(hFsoyNsw{6=wkY5@(aKoZ} z>(v*3a%TGCi!Pk`#8r^s%jLq1xp2{^mqi{b{;0StTr8A+)c9DqG*kR(@&sCvOld0zy1w_ zuU)#};-mlhk^8>)zGdfy&wj`Gd(K*NcKXJB?_73nxa)ln?D`IvblsS_g#MUq9=E#?P&Z= z_|u=b?y9dWx#J7zGtbQKIcxXy^*6s0lNf>>-0dbs#*SVnvC%w->HD!#cCDXuELeQ9Y&>F1}0i?1mCVo~wVV&cwH zqIg^JWlhDg=Squ8olwcsR+Nq`K_efi-|t>_?9q{3m)-G}>EcIjEB4(L`NPZa-=8ZU ze^=@F--e&M<;DlE{nWeOdDYUpnm%yJ#qZ58+0=g~)M@qW&iPT3H}72h^{svXc=tI! zTC(7>yDpyIf6-l$%bH4U#kx{`ad~9X19#qe-+kxaF}(Af^LFjJ>)iX7-Pn+t{QQ@{ zeEsKdy7{{7*w_Wxaj@peukHKJKb?Ko){)Vf^DkNU;il)VxaIZ-mVD)!i}rkA*=^y6 zFN&pPY;J!dalu;{1v-CK+- zyP@gdec;uNAAVu)+52DH^g`cXj&EFW?4pH# zD7ydoRPo6Fx8qwUernmm{olLxo(F%j`1$BFrG=#<)^8|<-m&za@Gn06t#AEy$$#G0 zchVcq={s)Oo6o*4jOXgV(YyN9k@}y!{Qlw>!o_P#Uub+hT>2ag;)BI^HTHx{A1wVt z~bkD?Di@P5_|GP^gPh2xy%-*$s<-W+G+yC)~&wOq1 zhnhZr@h7I1F8ssHC2!d~{qeV6yX?YC-4MTpr4KY-T-@4JT6ohUVADB`rE{7V zyzS^Kj=ull{ZBtqANfqFXVIH?hPNk1&p2c0&fzmhPFcM1ktk5M74L=#rciJ*X%33B785dooViuroszOxPSkrcb2~26e;$Ei|;Cit;WuS z#$LL#)Ej>2FYSvC)E5tLDvk!!^xUNju0DF<*NR`fY|(!`@%Xnsziiqm7M*hR!sjmd%VE1(7ybN!$9}); zvrW$rd~?&Lq2A+`=9`XtuFI^US_MU(J`QqZsHhsTzSn2Q$r<9gly7azqvE{0N zy8l;8e)Y{=tKKkn+8bZLtn}5=*Bck?fBuWDKP{c`#n#Acw>OS7{o>KaNGW>#&5uVO zCYnt)7H=p`Rsrw}ZwudW_M&@VSXsI8<7>?{K-uvC7BgG@}=#@Wt z@~S^JpBaAJ`tCDd_m1ZMgH2Dq{| zmmazH>dW8xaX`eC7ry1axg{HVcb7uTHWDHhEc%ywZu!>T6F0;^1S5awz`29r1=IUK zJrg-_8RrAMf^v^T~a!|(Yv-4*S5;Ft&Nc{7jOJ>=%%}h zLwD5|J0iuFMfWw89xkpgK3v?;cwf^CN0wrZZ*3|)`M%agzbc-3#k!h2zV==KfZPCwF9u7Z<0EsuD35~5Tmn& z)k&nY@;BRr1p>a$h-h2A5%;8}T?<-hi5qG()?|-AC?$X=7NMe&R!5bgN+xCcHUdGZ2M?GMt24Bd&Y^X5U#tA@* z==!yhv-CFj=sDOhPn9C{%_XyV3WP7PRj_f)DB@-nO6U z^jw6_fd8APXR_ium0E0}d0cko?W>?0UOoZ6+qNz5`$*468*SNB=FvFYj)_Z|QK(hd zbWo~mhG-t&z54c54`dER=DVSf^chyRmG*_zyN4-l@!MeUcegzt#75hKnzhB};@@}) zwxq$pyU%0wBb2;IlyOhnf{RvN49`Sg@{Fzm@}$NWp5S6&Gm30AhvMT8B`%I%Fn{sl z`3vF~C+gMv=Py{iWd8j4C5QC3b{R#YRUw~bdniv>V0&-KO3qd@Pq0l&3(L`3HmgN7 z^XQ)CXdsU7P%Ri{upIz4+U;;nL%5J~)EeY>%561gf%!AJImn`jtmWiSN;UL|T`t(3 zB%X^Pa26-^Q_pC-NL-R-%f2Y4t4nMM;}pAaUEx2&RkoyCA#{V@=Sc52^l$uV4C3o5 zyWB#kQ`I{MrB1wULTpwwpOpjZi=6GXnS(B#;)v%24&MYeHCeHnHZ_?xWhb^S;#0)d z_C-p54tUxcPe0YZD-9qDQm0n{QQ8I!nK^T&9`?ZRIW-P7_{6Eo?GU9Hgo;V3@)~ME z1MtgfonHMY-Od)KW{$jazsqu^|qmyH33Un5kzV{IqRSDxGuj z@GQYrOPb+eS>lFIrzz&51@!?Aq88cC7g*c)_{2ktaV{1?w{vgKiK=MUHa2JRB6yev zCE0ei;}=ez5FRbqj`G;8D^vpeeUCERq8)+y9f!6aIq8s3wFj-27qQZ*uXZHz=AM08 z>D6peE+pj3ZHe_NgBWJ8WnpBx97dqJwK-yE)~x?&wsRyk2Q!k2AG@8+LCsM zb&#z3ZFR0Kgk*2YzLB*Rd!H=8p=kE8(H7skcs3J`8(3Rqi=Xd$fTLEO1*h=kqY*3H z!6Fg}gZa!U7DMY8*T*0`yH?J$WHG$UZjfUjnBB}^EFNJ#%sCkwXT>oym}~d#n&oz#d=_Q@|!Lh>2kj%9!>nhT_@7GK?i-9G=NxF%plU0rr@Tag(tp zWe7fE&&Uv*!eZB+42yQP7lU{LrclPFDHv~-&6YF1EaD4TtbUPUv3eJVHL@7PM=%0g z#2{=4(FJTVgCS}5yc~nmYzc$0clI(vu1a4nA6PO zXE3~t;eO_(jD28**ax;!j@SV98H4Bm1TispWbChUL`AVL8AL|0uNVx5v#(`Le-?oO z>{}Uv1`zYW++z{AE60|+hDN$t_*rC5ZQsy8bpC1 z6a=ve2wtH0f&d1?{s>7x!~(OBWu~(j>&N^$1aKh!2?1k>;9#^Y#aGD~CXzv*1_A*P zMuwO%ghC)V0D&8b+(2*%qQ(#gftWZ1LdXhPgnuB013_Jg?m(~@B0UiBf#3pJ3@cl~ zA|Qns$0D*oww6IC1!6T26oyC-1co5u1i?{=IbptM5X-^DGR!K5SO-^WMw$#INO^T*-wmY3oCOMWb5RzY({pKkzHhD=NZ`*MrLGW zHyPPEMpnSct~0V@j4YFpl`^vXjBKEdY!xdTWg}b7%3N6)eg#*QGDoFsHY1zQ$mTFI zoRG<^ESi<=V`RG-SrZ%C9!A!Zl{I2z9T?fqjBGC>JIu&VF|sU1mdMENF|t%fmdqfu zNtVRO?l3Z32Dcbl3M2c4kzHnFHyGI&1|d+g5=K_W$SyH5oDBmbyT-_lFtTDsc9)SI zU}P5!N?LA*=YnzF|xCaERT`hW@H(RtelahF|r&+=1W~#a$FWR_|h8Tx7;0J z9}93v9>EX%I^wWavmaO@n;=^t`$2MX<;luq6|xU2w^dK82&M#@g4qpY>?#Io3Z|XIht8zqWpl&Mv)8 zJDbin!8QYJM%&D{*sZ&(*72_6Tc>B8!FBebQ|f-5|J1FIuBSD1@2genmTG_XSoKnMZoLNey49OhZ&khD z>$k2yul{%S%j&R5fhUFs$Lih8r7hYna&ZY{Sxq&l?hruO>vZMsrY; zrzzJw(R{R*+c&fiupePR$3Dh>i~S}07xsTQYThWQ(Zoix8m(-!xslk|rm?1wCHJvw;|@R;dw z$+N9zH_!f_lRTGuZu30lndcSlwZUtz*9or-uk&8FydHbK_GZ27dN=iM>)p*e*gL{| zvUjZaZtuh1Dc-r>e|Z1xW8+iTr?HQNPa7XEpCF%sK9hZ-eQx=@^OgHH^9}Kh^Zl!{ zedleRzjXPrt94iJt_fZL(~a-erdx2g+1=9p1V3NDjPA|4pYdvyDIalhC7zVx^2->LuL{>%HX>A$W2ss5S$ zf9Y=;pc=4yz@vd(22LM%dr*_kYVwr+shO@05!lmWmHf25BNrkRZxrYal=R`qfG-U+ z-_mm2IP&0Mv_UQciMP-$8nzkipvMNTk*Mj;@$b5F{Fe-Ye@8x>_DBttma;IS6qq}5 z_|8eFsc$+ik&zamfTNzp457vbo}4c38gbTp|GIV4}}HP6{X_|$ub?pVISIHfb*Gf!eIBJ z2(A<@F62MIu!rxI(Dcc5Xr$k)dNcBTvjaL_e0VUu@Bwwc+IP$+b~+NFEFyO`Z~~lE zpD&DSkFHm@;iCzwBhKW51}#;06*`0(Tp8(%C2a5@*>X`eS~y_@=gsK1KLTx={6Bl4$AOYGhyLH`#s~UxYlI)UZ0Zt{199V`zBFGS{Pwyx=5ih#$;$oW$yMmU5gqbaY($G~Tl!!b7@KpNk8 zGR(#ZM5Bqf<48Jp#D#sp1*bW*y)Vpy6Uk*Opz-7 z!{oU$CfB}%@38H%?G5Hc#=3!qN?#(a47z z#Q>b-;r=+w;YOV1N&e(Fl;?%j&6T37sk!2Mbwrqw-{CJ+#mBIFQ7CrCdhh~2wX3)s zuqSCjE_O^Deez69imLv zO0iY&re8exUXa5TC&9y%LQ8I@Tl$0JQv~2mkDCcA1?dB#V`$M%h%+6aox_zeoHy-p z1%90u4R9O3PAH=vNHMy_9F(B{bRL{goP%SFVj4Req%5vsG;kZ5s=%srBC$p>O=P`B zVem}-^&zW0258Ql?(|CW=z88K;2gH8{`ZLN^^5yX=Icn%dd>eHm>tYDpXkZ1$A3g<$MP01p@8>_3SKB|=`- z!bR17S$4k^KtwjwE#%+B3A!CAwklf)eC4j@m))V|@^Su+eL4R98o}cHGF;@~0PYSh z|7wp4Eb1Mz3}qM=+hOoIzV(>@lYbJ&J}wg5 z;lw;&-HIE_!Gk{B`!9{9%j<63M9bv_7a@UOPD06r74Fd`bssBjb`si~k||vhU5|^3 zH*m$pxE!6nU5+89gOmkjm!+?nxajJd9<|~Qa+$TX7wv@grg+MiM0FCo$3=hc#Lvj%!reT43MzET`Iq^2)AihK4U0%_9o7P#ufkwa@DRQ%th`|A$isZ-;V zY#zh8(J_CZo0K-Y+8g~=MbI`M#uv-Q29gdH$Q7MGaqEQs*OcsM(7@q;ZP(K3mhBt2 zXvZ&@Ica>hP`sgN>H@*;xOf*mkZJbUm9PZ^Ps5db3R!MyLt?AjD2AdGdaWA?nYn@^zo*U08yDbTw+lY{#xZKwKh zGdXcE>a0zrt596QXM8wI)3}qsQ~Emi<)X?-AdhiN|NrbI_kZc-TCR{K=F79}G3+rD z9>GlVNO46;)`<#O4DZlqN=A`l#77h>`YT}(d8<&b%M~C2Uw(ppCvF|x{iM5nZXDIA zza_c52wxglA!WWmhv{2-qWB={9^ulKr^JDN%be7QE0gNya_muYzx=3uu#n54%1&?K zAU^a(ET*(OcUjnhnnJeK@%DUo4u!A{mdU<2yK_|f`5T}l3tHjMRx1GgF@gvB&qioX z1z@Wd1ylewfmTCM2CmUSiCG3tCQ3;LB7neJr|v@aiiVjHFb;JwR4n*6yTq-U2Q^!3 z-TUI!2bo=!C0vcKs)XyR0xj{!KqPwp8>-YvtFxBiVtP{y_lo(&77c_qz*f_wP`cGZ zY8B+s0WPUR35`4J!y3C@oO-gfBeVZ0mY;#0n$n{Ra!z#0BJW|B3g%TP3@S3uMeA4&vC%@0rszqCjc+&Z7TqHLF(V$;?G@w@*zSoKCv&dfvYdDfc zj$jn`Wme51CHWB@;_hUWUK|8hG$y&XicD?~8VJHzOvpP7#bUf2g`3D`z;1IS@V&@t zpj{1HB(mU|L0n;XZB&r#Che5!Zh~DV26l`X{G1!B8PD;{TXKBv5^i%wTcMj!22R^J z*y7J!GHs-VE3h#CpM|U4al8DVh2Uernhl+~rKXtwQL`#*YZi8UXal#z$Y;kug;=BE zGh(7RyonL`On(o0wNv0T{0VEaqV*T~W7iq}wS9js#rpZBuS$P5^7%2)uev^hS`IAR zi=dWe(G=9e7sNnkdjvTgKp|ERH%=!Qb~P<$Zjq<Pb5Bx`Efjc4g8n!3non*8!hmEk%2Ii zzQ;@S$+A&1%QTklkPL)UGw`=b+Wgdw;yI@*}MV zb7#4E5nLQ9u3528+`JOOn7&)Uu;YSkIUJSDPn%6o@9qY9lTk|76#*j=H74_V1Ug*U zx+j$wU%EZM(=HmO=Du?-ZmuOdV%3azAk4(^xf9e|rW@x^El%tLmn`jNx4SUev=MuX z;BYBYN(X1|m?=i0e7cK0yP|MRude4RY&JELsE;MKy&eMSOzWID^P>+D;tXBr^+WI) zBETi_??oKBVOr3I8^C=hKxS-nVF2wDuMhZhU~Ss?U&2;*VTSo0hky)ioVnAc7+U_h zG)}mRtG5V4^`qNx0j9>3ifbxPxB_bmk?J{_1A9|HO0LSpy2o9(Jtix9D-FJNIS9y= zM906~)Tp^IM_NnHfi@Y>;G$9-l2n= zwj9(&&Ye0j5}hJP#YO+d=ndc=(+As*bJo(duDL)q?%-gef&FZhe+%*XHh#Zl@pqeQ zXFjhU2nRIl`FCGdsRJXah5Y~a_EW+Ey3E;29KvT>3-CEi zrMA$nY!r|RovY9P3%f?bzW8Y@yhDYl!XMzoRvP6m{kaO$>fzj1!V`BP*i67Dq8tmH zxpk(+lmnWS+HEM^Vcb#LC>aIVfqf{fmhO%IDy+a6fq7J@MK496whzS@|FX5IzEo8~ zMd;+T2gZt2{y79v0eUxzw^h1bB4FGXK`V*XS`7rGlK>_E z@{%do+*BqV7RX$-2cjB`!6 zF7y)Au9A*UFG2eys)IlX>gl_q*~&uskAm8Ahc5*0E340c6<(qEA|#{$vY?q=TVZ8w z6C!|@xeZ{6*CI|02@=X2lT!bs=rE^+)lx=?t-9})qixAEAxbpyA@$d|>yXZk*_(+#bRZfI9x z@RgL&4eeSCIaE`F>xMraW3G$qhPL+z_PFEcV_Hqia%s^|(rj!HXz3fNw?{|@KhyLM zLOKUBgSpy14r%^5|x8@Rk8{&%RR5AqP7<9B?F$M_V#<3rrw>gM4^jTPF_(dHx zvvfaB#J5`t|LCk#i+>nr@macBcsmWSo>+=tqHyQbr@fm(&ly{v7i@3R_g#Vu! zgE+f;Mz~=_oqTufNRD4T2tOoeL8qR|!BKzifoUTWmm@LPLd3ni*FTAaBspSkC6{gy zYfUlVR>E?Wt@n)QaB(J41#NsAj>t`ok!K|~TtFK}9_h2aXWX%Y+iK*~8Tdq3`xyq8 zdpd*bJ&As{=?t#;(=l!)E37;t25X;=u~bL7de3+k)|^2d6uM64UYhzL&kAJ8uk9ey z@~F9hyVV@2jjvBOh}$EWijqS@TT^eOZu*?+R7S5`0rhruwwj zBUdi?dx1SJjzqKcf)YVyE1)x3K@IOwa1?sMPt^D6Or|0re=0&;Ap~)$>UA7G%UE>F zk`PZ}9Y^3#`z>hYyBrD1<8=&?UJ1YvjMOcy08?#~iuU1!eqOZ$IRUgOWLpmno3 zHc{*%PqYu_vbo3DG`)kF*z|6E9!dl&G`uIEZuukC%?N@+CGu|uGwW;*G?!wKM7*IW z6?{`bNM}>>VL={_1&TB`Gmf7{S=xdO;4ki+-bDp);z z1iXHW3hX=|B8X7l3BiWCA5=vB#0#u+8`Cvj{jR;o@()+9R#aZ@=ZIyF{X)HU>X_Ar zrHjN)S&*2Ck`9vcw-oW&P`X<4`I8a6X^Wia(^d&Oe)*$A**ETBjOuOBZ3HP=d341d zYHk_D+p6D-Edp;Q0tFHqc|zKA!J%nK9v z#S=N+Fo#1jj?hw)3-P!%j+pji`Eo4Jor60TRW&7xO1?^Pl#HnUJ!eQp6l^ptpk~vH zd!OD{TFfT2Jzk`Z2N&GsG9ay5u7N>hhH|OEFQO1-`MoqhbSD6d7FY&if)Kdkj|5YL+|3H{hjX6964fE0Eiy$@& z))_idCGb1OqR_!ydxSkNjUgLwZA+D)m1#6p<+5Xzaij>{KowQ+7uDNtRiWkvzBL2T z!9re}wYir@`Ch2f+{;na)HjOmsz>>XELGK$m zPAzuEHD)oGM13LDV5}@BR`ap)!pjtW`_uOZfm{n#m4)vUXe!gv0h2u~sZ8rEsZ5TT z%H-%bsQpykt4ZGHTN#Aflmy*IgN7)-Sv^)Df8s-QnDAtV9l{paOO2^gOnCZm)12@` zw_MnF4g_h}J&d32Jrbn!HjJt`bs%obKEyu%uw>DiIoi=Pk6+Panv!si2!Q_?@E_YS zV(S-vj|hG$^*)Yj*mJhxyx==YS;pOu&91h~V$Yu!gD|LCg*JS^TXY&t)R(9rvL$GU zAMta<1cNBu02P_|88f^5Xo^=;-?uQB^7MW18JdDX1xF9cGB~g`X^v?CQ_;ma1a-V2 z2sV!xqa^;M4&qM12b2nR1iI5~vslr)Sbo4>jLNF1tAu5+MDc8l@Lg;cT+fD+*>))R z-W5(ikFo0GIH+cC%EA8K;LBd|QsP7Q`TLmo@PsBlxMSkOYLtRcwqK=*5BdE&`;Y67 zi4SAUi4O(+JG2fRNKl#$D*J5}0)owOU?;75_Xe0SHcOnGc1(De)kciN=-0cNGm536 zLPc`2G?0_UMaO1|4YD9Shkck0%s7g#xJ``V<9)tm?)eV4EuVNnr-|7~PGWspt4q zb(ms%&DgPGBUJBWu{#4K8te)#3t$Mtvsi*bJ(}x2v^ipvS7*UogNT0ryz^o`T$X26 zYryNx2Z+-@RzXk~bP;wVt=qZc$J*qX3piF)rInx7MXAXA^?rWDPTPK{;o%ZJZsPI- zkM{^D_v)V)YM9vq$ri06InlbKp!VV(rVDUi!mV2w#761-EU-f8dPca=+tjhQu*4Lh zL5+FVa!%D(sN9eQCz7B{;#L^ohcsxO$ya~^E4&LAylDl}5o>sJuhNCX$2mBj&W|xd zN6{)u;K%wW{hWB@oI^o-Zpxzh;d(6b7Wh5J;2Nvm!YFe;%Z*Y;pk8}s)gths2@aO- zR0eSbO?1eJ0Tp()G$vf=O#4H^52gvx9G}S~?MOVF>X6@_o4RNoHlGpGT>!8DHguQ{ zer>efF*<6o1#Rq1!hSjn{Pcvfng-a`nS}XLTY1KRwRI)|+fuFIEQ7H!KwLO>*-Vnaw;oa9P16;GA^Enn@Ealah4!Uz3tF z5hrEl#6UVJ$Tkbx%O7EBFKHsSmp=mAOPYx7>9M^e@I3Q4$)F)Oat#pvfH427|_K zU=;{0VVnbuD}`3Sp`tQO*^w{-n29;sfs8IAuFnaBaww^KFNWd=70Hi~)@TF10HzdN zVYCB`4utj{fYF61eNMu77DBz;Q=c|kr>lt2V>od-rawrO3jWsa;F*J3uD70``n3MUpzneg z5+yPdr`dOggP=9{Md|O)}w$3+HtCn>+_~BAUL9`g{01M@AQZp%bN6FHZEe1_d$Xqx^;-u8#%qzdTX^i zOE-kk$O z+O@yi_ko@t&h)HlgZY%TjoOognn7dIOZB`hQbP|pD};3j{7TCmeOhgd?o&0b#6E+hZ23ZoWlMy!|ID$*cg8ErdaG7*sJGd8PYo zyw9$fj-50y7r!?bLl)^tnIvj9yqB1cAPqTv#4dn_(|}Bb(uiF&6`-yp>Xf5yU;~X) zzsn>IM{I_ciqXo=q$O#XzdQmOPjJKBJvCV_p2lEaFFSC_ZK-c)>OgjO12zXIVja6#_vx|T)b5v_pNBPH&9y1 zKEkqk-;{;c^wGS6;4pIwU@i3o&QZ>bPm*7J*zA_gv$6BQzEF^ulbhvqH-?RpM)ePIj#d$wALP ze-8#?wH%}J#@Vy^dAh>F+_XmyPX?Be2E@jv8?jl98RjpyT{{0%`)k^$UVSG{8aq}$ zbZB@)7YF|3A@ai(p(SaMD2VSh+qvz6o^%OZt>lxRn7x8FpED(76zHXTr#Az90tu6;2xH0NnLQx%!dH0fT)aNMkKo zP3l7+41;;l1@qpcU=}8~Hzk8e>;9p=hAhgsv0Z-*Rx84ktGpF?E5b7ZwJqJ8i336& zVhD4s&#Th&#RZ8vY(8|$nMq~Zhri##tiSQ7ntPB~5=};v5VDR;!=ns_LjtWd6E~{f zV{$7%b*t>|?W|EA&3kt3rdL0vmT$FB2IgL66~KgiKDU~MFPag`wxlNshKY)oX}5}> zI6Ufq#lypYK--~5#$*rDbqx0%K&%{yqOuEU-d`)cXh6&OIYOkAqye;6kYdqOb2~e| zq~z?lL4hO2c~8`Z7tTq|cHncb)F2{s;N|BpZsy-RtB2uanPOn%;E1kT^;jZgRsWo2 zmt~AAfF%X%jr^|=PXrDB^20CJe!aXPbqsoVO3M!9159j<1l z-M)WvQr|6@1NHKUenv;FTi1SWeGX4G%*Iec;F6)Ef*oA$MuBXW4!D`gbAlcEO$_hc zH6r!K4|rw*|EqJ{-Jzeg7vZ9&+aZPPkzt2#I$X*jYo(+Wh`rWu%KEoNy2McGYS-2lY z!xXjvrpUn^Mr%q8ITtR5XZIg4cFe#*S)(uMYb9;QvvVky%n~*OW}Op0kSNS6%f`I2 z!Af-y+{t2LS&kf*9p=dJu`t|1OBO@q$#6#_hGf-*APph6XA$@#aUMm^aO5PzNd+<( zB!;UTOsQ&E1>3~An(e}NPETeNcT07cP1c%;YG*Mqnl*?G<>k;)1D?20J&DIj%=DvI z;5p+I@udHq;9SlKsbU*>>K4xHlh~Wh$0}m4SV6ie!JCq!0$fDc7XGhLdu=dSIA#j; zT4FdTzy;z#E|5+N(rq*6c?}!v#E(dY_b6CS#U&g%bLE$?`&xcV@wu~Cx9Z0!r>|W& ze`??0LO(4(B8TIrkSHB6e#${F^xt=Js%C zF<(b+s7T}O<4RXnSr1e0Ci1K8!7J99c#4^t$0hmiH|xj=OeasAno;T?+K@jqe7slh z&=zZSP@+O0A-~Wtshxv*Hu|K8(CDUrsvHi{!AuY)w847cJGCYq91uLw;f*ucM`JMjy9f@0yo`~hH zU48W~vk<|a=o#p6j+>S*z}7-}DO}J%9~aVRBJ?>VK!m8G4%JZd5=G8SOsj+$H7W(> zcB;#Zxc5c!Q=*IJrgN{TTT0@y;}ubYyB74%DnY-G9ta{|77{|_TGBA+8MIubhuO-l zXB8y+Ev%O=;*`*0d+7^kp#Agm0LMKvTe#sep9iQ4l1?26riRAod*W9&@`i96{*WI> zjL9>lPt94xy;q2570@=5I4Tc=r{dwxvq_hYRi)7xS3kdfn%_H{XF8W zK;^epb%z|(;v31c#Lg5w)eMh?(32k(MM}~IvQQqYNlRFfvopsoD-(XsY?KAdHJQrf zxP&9eGtvj#(DErcspoEQ!J|ATty?&6R%GO*P%Zyq11o4*K;|(5*iQz4|FTu^DYT`vBI+A57-d)%8o+hmu3srVZZJLm#de`f$fkAFf(_ zOa1ihA2d5*426WKO9TX0A#!2TXd$UY5vdICLPIO)kY}MjMs8+GZumeh`6$JCk5-Ob zFez$mHbR3}A!siHs?wqUDYgh*zlGw|4%{YasCn^A-iw|3QOeMm(Np?pJ%(NWfUec| z7p^?Mkv4tc4#Yh?TN!L{*Amvthp6@FGObtU3w3pBsmc6v;F$*YQj@a^z$+huJ7Bm2 znrI5Z_m;BiVp64kld58Hvb&DhDhmke?4CDN7n2ta)~{0yoiY#4BIpfC#~|p~{p00F z*`$+TlTwxZpObR<8Im+m|LyP40P3}Ui_X;g&TZ(S0q@#DJ^eAOX5J6*U=XXv71ZhR z$QS)XRz95xqYtRiq`W+>ENQPuQ--`#oZNaKKFMLD$}sKVNS(hjrJ3e_it6OPnA!UE zs(H&+OxLMjRr_Q5N5gaAmIN_p@fH_fpxFH!%s$8FXTv1i2O`Dpn#!9u9zE`L!`rJ@Z{N;0dspet zS@jRkDJe1JmR!ml)xUql=mC06DrRvlvly!+ksXAg!iggoXWWHxbc8LOuRV5v!WQOh zI}N~N2PO*B5anQXH^E?su(s9IH~PR&;A@}~+~|Q252OPjD9=R0x@gK1SIEEdLm^%> zk3yjzVOG0JU2T0m=_m_blI1XNHg1FSp@iDaE75MoqmWkSk(JbLzMN&Tn=hl?j0fAG z-AqZ6-F#V7y+2E`n^T#b!WvbCEydVM2v!Ls&R&>Yf`+koM)kf7mRil; z7NdFJSEJcnCK=9HIIjAm*>2t^+08RO(y9-qvCtI1j=LV3G*zpOV~)oIx9?-)T@@43 zd+)umH#L1l#nO+B7Xz1l{NqOJH1xCg979?p>i4?r44yZ{aeb2ssz25r9=bAUnM-5R zV*af$&=f6YbIW$%nl~pOtz{1lG`*P(Ef(mPzCl}gLb;we;JesozkYBgA3`g(J(U84o{`&R0x|5Fd&CdHj1ML zX`n4At~|)aEoAVpuH#8VEm@97_xR!|SJP2J=>TJ3GStU&k3va{A^m#~U6OWfyZ$JA zhhG+7ZjAL+;PbNSMG63*=hH~RdI&wd2*>$mURG>q|X)w_FuUQI5eQ?wo$KUL7Qx*i%o zx6t^BO%$ppT1=If{e|kCm_`u@_V`7*p)kg~-|0=?KIR*AXMT?x!5H(Mv=O+VG~a1p zufZW&hu&nerLYY*f_8Ybho%AGM$icMA>eur;{QKnAo)}}TMirL{tVJ*7hkrR1Uv?KrV!B|5hYav>Fv1>vt#c$7C^@?peS1T=wf#7< zckLPIbaIKXwueBj(&U09!G1V)e*o#iNw7X`cM|<}AHi*H?V%q(Q3vE&?qBCZ@gLD$ zw{LCIi&N0)^bf+?AdV~(t!4FBJbCtHh{=tzv`tT}}d1RE78%G5wn7B{11rdTHvreHiH4+BbfiZGtB4{ZYNyXVjvnzw(l ziYq$(MEm>gAlx8rdj{aq7yM_U7;*K%N;G;~ZolUP&9&-{u^A$p2`db2VUei7xyi@5 zY4P$UH~{bcmN@$QMEcLvpH;#9pPoDiTWw|bxE_1-1_)CUFTtGo>%P*Erp7*37Xd21<*< z?%+{TA3YWX_if`q&{-3V>Iwg5lh|I<#V_}_${U4GpWGPSwPT-=-g^GyI!u3Y_A31I zB4&r2$rCicBV(i&KDa@(9jir`H&?-KM{#g03MLs_I3Ww{MS! z_t;fBgX81JY4=X*B2{G0kA2)x2X`Hkdv`esyykl=`hrTk6UsHjCbSf_6Xtdq9w~*-};YrvumpWe}%C9UN6X zU7a1dx2tuoa>XA#t`Oxm>t83hW3*|FcaK&MEpCng-2xrCpdt;oPq-aZf$&Ox`L1SH zIzq=->tv3o4^2(K?EqiM3k{#^8`|IT2OZ=iTo&pr%9z~2LH%Ps_p9N1p4B#R#F za$CbMe4yMFxg&Cn!}Qp>F$;8qR?Z%|W}J1ll21Pqmv}tu+P$D(wH3yT&*JoBmA%$X zo<309w?~n%Dt%R|4yL3k`D`*zxia&6L$SllE0>>Mw!GLC~pYM4?Gc8U^t&sSo5KK5PG#sfn z3yIEDNOb)wBpy8n&LSj?C?o=@kZ9Wsr+1Uy4`swHVuw*)lo4D%lo5|_z5&HmNk#+( zw9u>Lsff5wMMRK`22Y&Kl>(P2BI>K?i?N7^8%6U9A-_lx5rsGqHOQ%m=vyly@+A@B za=UqRXDT8JYDGkEvxraygRMnGbn{0Majmeb@+yjmz9W4tBErQNMMTy4-(NpM5z&39 zBM$7Vh$txn6TZhJoI{UaQABLeSE7jM1!|Q0I@eke(c;mAw0kGaBBGZ&sjGDzRx2VN z^#!F_L?}We5pk#NGjJs+A$Up-%Axtqkro+&7!4{VR#sSJ64audR7$wTTJuRLC+Z!V zM&*PF<%Dl&KSy+ZmqAuZ19QUjZ zw9JfHxK;%tK>1cC5+9lAlKv5(d+=s`3>J11}+Om8(p7!T82oD6ED3Co7 z!u5F04y=sxl2KQ)vLC^%Ht9nJ=n)~LNO|gTCK={+6($hh*4%#NUO=<%eUdTly5E;N zt+|mAg29|r4KB&%?62;W4o$G&X91IsVN5dgH}d5%QhM^k7|c(85rYZJvnksVY!NeI zy^=xGn_E_s)|yMejxG775R?p^I&xIZjQ%vpfFvi(*sQ)~VG1gQODh%VG0lENa z!a~Pn7=Q^2F<_XDNY?W|V7@{OO<0I&!MS2f*9B;h+zV4IzVvL(5hAs{JKCUx#@F;U~@C#7au;f7n@tQXPz!$$8Ky48l2RO zTsNb(xVASe-bssTW3(^!d0J(fJdXROAE-ZFle_uU(PD?6Ri_r?8G%ceEt zjGC7FXf=7ku!S5sHtb}or4a}krpeuO>R7SE4%JCZy#@Q0nd`05k@_=`|8vl()CYqW z*?}sZPq`DM$=Q^0^r8cQYc)sSn2X>}mEGMcMAgy|)cz- zMW7RMJWhFc5`YWQpTg(O#~7Z5qp>3eeoP2L*_(44IXLOhJwx!Yz=tC#Nh0lY7Y2Px zlIkOt{5q!Io@U8+Zo=uV`oSGo12G5A-)+!<>+E9DCCo4U~(I-72>f4J|YI$l4C4vlBX3JU+v`X zBOl`FslRoZNkch}l+#$-Afm4`ippROZ4dk>bAKTE8ks0hEsG)(AIIRrEs+LfdH4hg zH$97!rVBFj@p0aXi_l|B^Q83rgXAM83n^537*VSD*H>o^y5OQkHwlYYDIT*(I{&9F z)R)d%)sZ?X{+&_aUy}%!t!d+nkst(%Hm`W}`P2RGH0O=9a`$u6sig^gR-1zN_2H@@ z;om8E%S^WP8LH?*R5jK0r<#7(f1N{Ers8c zmZserQx^#K`Cdy*aa<@dn%G#>E% zYd}#8V(;D&vvg}RjG{pWs>`=NJ;V<0fHZcsV+>FL4e zJ~ygR>s8^xMuX!edMNAz8qI$|A(CiOEy9z+5qSF&7T}4q zXmFT8l3~U2{92IYas){}9V1XJ+YFK{E`ZlX(q)=DV+|Tiy+C!Z$OepGe{B-m>*j9TPzJMDdV2Bh1O_Pm_d?J8OMa@Ssl^Ep(4Lh zkR<%2BB=Ad?@UPO5-2hubDwP9c*6DP%IO z7BZ=!1E_^emLO!ZFpLA6V5m2m^BYP{mamY>{PQ9nIu1z`GWmNgWU?zR7C+)>hD<(n zf!0-(Um=s-DP+Ln&mk0wI$KaXBxLGZc_^6`e6t5baQ?>#hM0a>ocbj`cV4h^4TAtpXjFHwM^;t3%|b#z2xs7GN7#<>8{rYl~Ht#Gg_NH9DCPoaGFl3i%#%s#1UHtu3-%H&CuqoH;155Q# zZhx23LN9d>G}G?rNqIVL>DH~Ynb=Br@19W+VU%&WnO0DkVxi%@BBBVGN;$Z!(d@zl zD}l6ib?bGCQ{6xB{L62ChbSm(h>=^MENOb39Jyc)ZweTupf^&mX0+})JyQC zn_RpEjX0hohvNv12pfymmRh1c7W>E_Q)on=teQSZvp^#xPJ~8;g(D{kqtJ*x28jWo z5j$|OMse;Qj?AK{2&pE_B5U_>=rlk?#3w{FAXbZ71IfT#|bX}jPMpA z5pTC}@e~sAkwPMt#R}35C}|5y1VvmxPz3%j`DH9TGX)$ZYhgiH#Ce28oVUOt;;#uW z5f-uQCBh=U=W6i}yDa#J@40V*4z&Xqh;lYFG82zNv%66MW|%`RoUVmA=(jvY;Y48$ zhO5Guy5n6>%?}O_45fG5#sQJM?eb)!kQrtcxL=f6=}3B@~#EsV08^oS*Zm% zT+I6;PDhSor9)G`f*f+aLqeLNKkzd32U4Obz` zp`#h)&;}Z<(vg6#D2I0v%E4*78RZaPAiSd}hipVS6x5;|!YRsO`sz6=7wY=2oE7#J z<&bzH`}(86=h`Zx1?8}2^0WckzJBL-_R#Mq8vh8NXSG8L6pNNL^+`N3`Ug0AVfLfIVe*o${_$3RuT8CNPZMi4kgb0qe_${ zDDJ5vIBUV~;R9&4mjd>!&>!HaCF+3>p)L9YhD&aMh_^6Tx>$n0#Im`h3hHQo&mH2p zn_?U^%X*YiM@3t2)XTcB(f7x0ZlKr}F;IjD&S7qa(pN$`JfQ&pt1KXhQ_S{WMGGx2zzqi(SYm?0xZ4E!w zsbkz!r>@wlp?F`D$1^GP09Rl9!FB`lEB*p+2mA@>jqEx0Jo~GRlX=M^WD8|Rc^~{e zq8xd-{C6wXsu}(k(Q^C++hbNnt5T~cRmR(qu8@#3aAZzxz|w-PKjEU%h(oRn@ERe*MGOhlk=< zb~IjPAIEFa^Wm4mGs8>5@9@*tE#ZQQ6R8<#9yvMEEpl7MU1%Jo0p8 zdSrHFeq?E6dE}GGy2w|N?UC;y{L?3`dRoJ@R%xC1G3?oC=cZkpc17BNw83e^@nCd! z+N8AKrafIN+LVPqb&`NrqQL%G)A--Pv%57q()Tvm?>4G<0H6It?!OqYe|Ym1@9|gl z*7Bl7yS=TW_43wy_`!?WP59Hsnb)w;V*4ywaW^lcpYqfv-J)$9JQRJrR&z%GwHf`_ z-mw0WMzOk#{p*~379Yu+zBIb4a=-7ud4d1$rtka1QhKR>R=&%BEa@M+g=Srs_-AFQ z*?fE|pY8kaz;n^IbG5&n>Vi&*FJB*mV+Oc*YM+VJ$_sUd461`&z>`V zI(}S64;%iA(c;HNzlOp(l>9Z+p3k9vm3U zE*gmUlUQ?3n{dkOgM;T?c<$iq`#h9AKQ#A=7hYV{VBxe|M?~fXicY=Ydu zy*}14+9ftLHYC<1c1mmztKFx>PL5uWw;8lvJ3a}znwM5vMVs-u>BUh#k>Uf1tC>MG zja?{hYw?NYeMr`Oco$S%uMO%GtL0xq(e5ob#4-O@e7gqPY zKfP?v8PvM*b*Kpb^b#5tOKU}DQAT`oYALUcKSpni}K^rNUd-82m8Fg@y2f&ycOOx zeA}fTWq-9})XQB?2)=Ro*)Q>>oA37PhVY3qPI>#{>^4n;A4l`$r8?i?*V>Q940=8L zV5frvgEuT4`|*bQyz;QJ*6HWJwB+i>MV*TV2N(B!;hfI(7c_cTTvFXx)QrD%;Z1CB zY(#W#@Dh9q9c&s*A2DRCY=4t3K+&m2tH&1ng>HNs5 z!k>TT9^vO-FN_a9{@bUXc&5Q~Q|}u!`nCx-kD4%f>O=5tglWd#A4vMkU8Gj?(|jwy zh7ITe-v)rp48K7uI^trf=&Mis_1UQnB7<)kb8X+zugsg+ct|M1i!l$}-=b0Ez|*yI zX3hHW_l<7|-8k{~+s5GwL0nK>;JKh`{QJ~Msn5Ilr>%H4%Kcb98x6nhraQZZacq<$ zj*ae!J2raJb8M83W1~*u*k~q>jb<-iG)Ej8-87(o#g2`7dX9}=5ywXDZybhWqi1JU z!>w`B2)Ad(~heFYVu|vU?p_p3~ zi0+E+30@qE?T!@$qXm8NO7#4jjqu2E``BA2-;PHP`FmokMv;l(i#=|QrM+GBte9jX zwf24fZnSRWq8WV-zLeUc-;mP>jpCso$B@!ORiZT#8dJ-^9*GqvrctFIk(ah1quR`2wi(MWW(8V@25 zKd@*5E>f}|z%|dtS$1L47g_3^3eQ0Kjh|25>v;xho;^0%GtfV|^Offr=v3}>8Zj6< z#SA`ijdsc{IwKdCC(NE}MpITrvv>9S;H2l7ZLgX7)>~iJFKoF=Y*H+~2TptY$~EIz zeD65+f}Snw$8dGyeab8TN|H@2$h>~}-#8+FB`f)Jr?&`qa;-DYT)g$mf4sG2%SV0B z?9j7+7hQx)snxRU+flRcpYQG3wV_Xon8k0*D~&eC(qCy5c`p2DBkx~@k=&6v(d?X& zxaGpjiF|Hnr^&~5fu|#58}Ucb$d-5KEScFj(ij?@|JuggC7^x%r^8~w|*pN*6a^N2qJqPl)n;2r zp9WtT_LqKF)PJ{8w8@^2qbEMv7&kh64IYR*%U{PLu@`GyHelh}#(V{}Zt-jH|9#PQ z-Lvt4bM|$EF3lz_g0*-?!diUio#V&;MXbdbyC>orYF=n&w2L@k!MV}g4{+!v&W-q6 z>mM6MQyB6)vb*p`sL&4@_G;+gqkkLGZ4#SyqpyA zUjkpgO5PUtUjiQ#x@pq52u{yx=5_0R%gv)2N2f-&FFsiFV8*klIkO}6m)@Uq--7Ju zs0H#`!5yJrF1mN|-|BxI`IA`3$Besu!dR^1FJzCq`<_vP_bgQNKuu0!;vYI!;v}B^f{5||B8>GJLZQcOhIRG@%+>~ zCMT&RoC@~1@h>Ow!T=7X^ds~G{UL44c3LNfU%dK^$&K*O82*7anvbBn&y98eM&eUb zBg4Xr zZ{+bt{*+WvKG}P=M_Wgmx7`-29*H&Sc6!XoPCu1Tt)J}w)WF8)G3`odTKsN}ee=QF zrr#EM>~HvG+*j*w(c?!vb55*PF!IZ=_h;Wot(?2UyyqS%8Xi12JT<+^uNN1cn;Ypp zbZD;!8%KWo>(>w1MG;i&SYAnqj(-iWK8x=dA6+#+x+E7bL^vSj-Nk_rJM==d8)LX% z#JUB=8=v=96uvYu5#BlXySefnRb&KaGEOO znet(oF--o937bFItBiAAk6wLE`pCg!N12A=>%|z?71u3zd1+-r-TIy1#B@BjTi+(; zjB|Sv_qepzc}>jay)V0@iRst3_r*No;cmwgy9AJBA0dWhX@KVIRi=G9=LyVq< z#OI8jrQ$fI`VFIR9c_kaJVN798jsa@g2wl1JW=CEZ~EDoQRa6VKc(?xji+fmL*qFb z=V<%}b&`7juvkin@1x4Z4jxg`{k)k^mYb0qi_)Xh%?QZ7f1N1U<*&Ec=ZyTS$G;)sc)N3tUIBkdv`BNs-7MDAq*{AlENk*6Z_ zBJZWuPHTbxlc&pWd&L7i1~^6G}`wyN8`?lt&4d7|#* zy18}N*48mWsKDesEkBuSpJGlpMU>ICv~_@V(@i`KEzc zKmekx!qI~rHpAR}Gt$j7_rd|&+ymxY_kiu{Ms0Vu*j{8pwxEJO5*IFSU z9Q+BcLM6|eZ?eEVOP&knS!JG`1@P4||$a=3?i4C+;MR@YqZ zzHP1{TubOj80IcF!`1mQ-)&j~jXZg$@?qX@SUMiYKP5XcWu2I#cF9H>6W7Pz@~ zzxxkc$T8~vgPIL<4^!jK)U*aTMyP2$YFMAzRI%N0jdc<2*VEl($C@lV4m}s5#3o9t zqV9#j-JvZjEh;rQ2vuX>bJocG%3Wk0a^EnM+;`2xod25eDB&4*w)v~O!%TKxr3Es4 z>NfCB^5`k@=qd8(De~aCo%9sD=qYy5Q|zLr*hNpV3mN^0-Xf3Q zB9Go8kKQ7W-XhP8albLQ62=n75gs6|Dp0mjVg+1VfUYm32d_d;5QaMo?R|vDkVDBO zFqZ*y88DXta~Uv~X%FQ0K4p5@|BN;cFlup-6Ni3ZMyC&h{YulqtfAj4AXI@1s=@_L z;DQFe=1PML8c`iMm@-gt>d9MfGNe>Pz zH=pNkgDT#rt-!WmEYlsX&m!fp*-qF&_?qwyVJ88uF!_YNgnfhpxa36lTYHlGwQbL_ z1IO;hajKY*Q+DA5abfrO2jJi|RbH zwTuPHDPuve26^@cdbpQKdqp7>f#^Gu%KnsJwm zvIJUpf>*mJkT-(UDj?_kkn_PUAIJraQTKyoA*HH<(!lMoa{ZO$znY$NAhjL~ zt;Zo%-WYp_eZkbWuRwQ39d;H{Q6CB%fC2?Pr4T$1@Ps&?8-czL=)&QJwlyI^-$Hw3 z35`V_)`9;fPt%bvK@H&puNBjjd%ZgBhpy{^E*xA0)O?^F2H#>J2`AH1NO2YXzy{4o zY9M?cp@!Amt<*k)nrBF9>J>)rBeZ-q_(|Z+;e0N2o9}*P7f>pP(XmA%t=9OHQs*)b z--9+dtmAO+EE6rh4SCIn9(h2|@~QqT6qk0}q4b!|aV~V3hh)unAGQmi_Cn~9gGPFd zd%29)DuSL!3(yzj@vN9&i_RRzxMdrhD$*9_=@CJAWMWwBz4&cvxw>o(kPe|?H_qaTII!vJN@}W~!pRn6+f%j;E zLSVekQ^3-fH<92&loAOkB(*B^t48qTTCglcl7!#kC+2=U-P9!4ZrZ&XFp6mVJ=~w< zYlE%iC~I90wNLsB0eFCL^NC_LgP&Jq%GvWrk6&k#M>2+&a$o*^}XvAiUwg7sYle zwRs+TP9skh`wVUQSAxei2jG~)mB@Wqp%#(q^`w4zXdF*@6)fVZo6(ih>+Xz0=2>1!ydRcl#T4c~@*VOqJ%rSW zHXg_5VZ3r;RV3~(@~{t1+=o0ABM+P5#3DE`##r6!L#5A-(o=|?NqT?L-O@kOGbkQX z?*slbkVVqwavhiB1w6r%GU(whe+=f zA4h#fDd{y!`Ux#L5A0?1nS-poMym++`Pxr+Wu)Zw@CSghgBon+*(;O=iC#zgVCmuW zz{RV@e2xowN)Dq4PZQB(#8Tz@>A_yWDd%iMc~v{yOK;(9i?szy~V$mc1G!H*{Z>ouN|%cxw& zhce!g@!t-=2hRsj89{CZ&u!FL__WmcOL6z`&Ij*9;9THC%*X2IwW-iedhZQ>OL!2Z z9p?j0+H#?`r|88ZxOpzAawmt7i^d3W76a;Gy$bQnLZ8~gLp!L6(5Q&I%!88imG1HU zBDJ_Hw<~$tD$1<}(i+ue9|7+lgcXF9@bM=cR}oed)~H;sq%NP3e-(G*)L6)NnIK3VQ0;2+XEsrExsKLYC&99P1BnuF(o z0Z`?xV4Wx%uaoF9si{})P*QCV0S#a8c0Dxs5*mC3j7>^|bwK)@@C9K#wcN<@OTt%# zO-h6H&|o9^zvOPbT!6fX>;@nLji;{TYCR>q(vnX~3k~A%*8^b#xjaaP3Ol4vmOBaf zhxAUW{fyLgVEqNh_0T|b@H{X8s@xT<6J_Id5*kQNy>f?=YI}(B@+*vJU&WH<%}ZNg zoro|mZO+WH`S;vSjLj-Kli8z;3q^k}M*|h3FOW$z&p3Cjar)lRe9&Ck>6*jl7iJ)9 zR|Slbf^NPWbGHx*-EDf#ypWv{r(5Ko?I8U-H_zQms<#)q2hauo!OeZoolFg0W_0Io zVDzO4Q-{sqSm5sFY`r@j${NNLY;LX_W#2;jPWN-lg~+{*T5WVUxjQ(12L+gALBV6Z zxy#*Wc=mK~I-r=vc=ledcEiOPa26Z*{Dm=h2B!q9tk^iUYvY~5$G}BXzfMt``-vF z!C^PoXbMKahNp+(k9XgIlcL}sh2N!K{vA?jtqL%gwdiBMxr^OD!<{cO5AvVJm9)Yd ztxG;!zXO?g+At3%wZNoOLgaWeoU{W;D4>o(cL_Cq9sc{!UBxc^zFFGw*lt2Ieb#Dd zSmg09*9Bmft9LdKZzbe`%`SHXxEfy*ZNSE^_lS$ZJIDQ&_$%sB#J=L#>mZ(51WgN} zQ;3 z>wmO;1fQeXJgkp>ADh6fEZ;!YJkcvk{|d%mw8ri@-90G&6}Eg@g1-2tqdcqhYzb<~ zxC-4aW38_lg(0PK7mQ1d5E6C&Up&KN^&i=^r@=;$c(_paD7T+rG?R>(hbf$p)Y~NA z|6e)R(O44I83X6L%#|6X9L5S-ZmXDAkkXB+HDy%)Kc*$61>-sro8wQkmFs`J-iL7} z2voq!w1$lMlSXF#d`ia6hkx4f8sh{bGr#x-$H<7BH)+uG70N?Gt`pn=kL9U0rH4+W z#$o<F?(Js8SLG*|T~|P~vPnVK<=B#Gg@*hH4f!rd;eDBt2vtjXpYcJG zPUY@@dbDsPN~U7D6Fb6@?w6&-PkJ4ny~Q8OY)b5SQ79gDnSGbt;(e2h_Pr6mmqS}F zNc>+~&$9JMx^{%`O7l|E-D7kr^QPp@-4AykKsIG|1V%^Nk|jj(R=tzl5=SceEJe{Dqr2Az&Yki3}t*HFJktQ0?$d@sy56Wr2(imyp?~Ag*TrCVq8Oy;6$elkAvuZ|AXe@ChER!Q1l#w(@z&i@Wve=IEAzoSjR`mvyWFPy4g|BeRy02|8| zMqx+bOGY#A5I%tV1=@egiW2VwCO?7EPO;*C1Q^MAe}r?#dpbPvxqFCFB2Q0-nz(XT zy6+R1dzRWtVJW#2t8S(8{iGKjmfncm{YSV>%62i*<_}zVO>03F>b;;w~c7AdQcyY z|I=)&4#!t7v_Hg#S}aiGlKZ6pG|kIn`a@EDTZdBMhmis zQC|6>2-<@~Vm_U8{|J4SPj_9*PvkzfXXR{qdu06U;q87~*=JK%vA!1{#dxT)k2t3J zO7Q*{GnOA0fpTloLF9BdRAlCa?xs;=PGd z`thXm{s;Nw9UO0-rxNggLC=(~>9M^ktHX8vD*9Wf$vZUVH=#WD)f$|*s~S=`-8s?- zFOm1hIRg)D{=nj0a)HQDR1#5*+Jx9q8iC(-V;jnZy5_*a?bBN=M9IA%!Ss1m#pCK z|li8%SiPJsEJTy<&I)q!i{zrIC<|4`Krpc6!mrX_>gE7OW$@1d8BSmfam(35~ zq!qzpeWIkaU|DWW;+azSjzrr8w6bSOn3vS_sCh<#L&1081EH$Or_ggVawPo13Mnm< zik$5t)q5vN(&Q~+YAiMyvC{B9>zl~$d+r|kU^d=1#HL+#Q|R&@(+bM4UPZpu@&tLN zF9l_bRN|Um@nzP&-gl=79QQ5o_<-7Y9K74qqb5__X9;}ih7Lkg!d+{ro2M~@j3QXq zlq=7M3@k7zySX#D|0m#zeprF-`kklExT+v`+0tdN(QSMqAaj4^d3hgrJs819Y$bCw zcNw(u-Ur@|ER<}tgi5ez*)m%RMoQv`(wb+5Q9rff9n#cSyfSO@qK$K@bY09RbB7CZjKe*FPcnBX2ka zs>Ct)TU-NV+QmQIf`6`OkpUd?+tHq3G%Y-mnd$@A;+~$Q!^a*L{k2 zwPoE>c=SDRi=mg-@g~-44-0oTn7;@eX7C350&1KOM!ct6dehn?$=v%?CAAo+U%3CE z#zjhl(%CD0rTvUQn$n&%Uv1KH{f-U;pF?Of(GJ2d{EJF^WJwXqZ=%1KFI0T}O0APP zuQVFu^We~CwCgT~_iyw~WuU)R`Wjq!z(rf&ijuVhFrX!V_~zN?fhhWM#nHj|S5NiY zJ3%>g+b8hH=j@wQs|n>rLrOoYa#{{rMPo6|RD!#_7xba>kvHNl2jY7ZdQt>>0g+NB3g%av^=p>~if=At>_h@%qRL zZyzC%?}tiyR4+?1455x#|DaF|S{-JkS?zO_iO+;fwHuUpmzuBigY=H_*PTjkGTy9Y zR>|g%^2?Tp&r;|?cGHIx(i#et(UQ(zNGa(p!1sqdvFxLc)paSFR^}n-#&wJUK7j5s z3DUFkU4d6JeykV{t*YO9R(!YORK}h;rSE8O@wt~4mmh3A`HYHUSaOg-u*!qlUJHnA zqJ%4$bFIK~^d>px5!Nz_Dd{;&Kc(WU&r*vp(!$30$# zcsh3~^Va2ATFU>@OwYTrx_c&nA5S!`%>^ad zYAEEA%cDN=-%CxDM_QzRW9-UI2aZzBCOQ(iIfCq9ollfcK8xE+v@G8y10g;#OU`zb z=d@9kQr~@SV-A!Bwd)X?!(-meSjNl3d*G;C`!%DU^@5AHC3=E*ihq>3lWg_as{%v* z6((C^M4^{Q8&xp&*4b17v!jJvx?H>sN~c!#3YjkSW{S)-ix~lZSm}~{L;o-H#`}|` zO!R$a-fB_~^x8p3rji+5qABRW#P$ zv@d!hK5Hyx^OU;zeKYf|&CI-HhB_08{FGT+q7N(K-HK2q-;RIPuU611RT;0V#!kRf z>p4%Cjpuvx-beqs1^COc&3P&EcLbR)@a+KU&{*@YVg;b4_DwTL`;1Y|HU(74DA7|? zqCi!6X33HB#m6?#eP>+13k5t`R^HHYTm@7~0(wLpk(B3XPm|@{qw3K@s<>R7(<;1s z1if7!1+`C8n|{39zklSP+hsN#56GARYZtQu85ekO@|Vf?{g>`0FAw@+A5fuz->-gu z(Msj;MtcdE;QSF*j?WoqZqld8C7_CCuODGS^`(o-=?iLYA9^F(X1vig$ys_dL^#1g#?Yv9|QC(l0p{?W&=g5H6D zW0cvFbtWU5e6<>`2L4;Xe;=LwG5y)M;OL-Vw<>R9SM)aO{sp!VS^p7B$qp=49tJX3 zj?b6l$9SP59ZTmceI;e8f=8KIyd=l#k0eNaMRs&_hP6-T5ETwETQv1YKp`PU*m5CgBmPYJrRC-gd z?_ouNF=hto{P|FE@amEW_4(gd9D@6p`*ZM{#Q%un5DLY5_M0#c7_sdaO{_c^Q zq+8344AERN(<^o#1xAL>5&}Gdwvf>moWqRIolaZ7?7k>ZaUW5--=9SHJvwv!Gxv7u z80|y%52bkq-L}uC`xm9?P8o&iwKMbI@>^o=>D^Ph`(LUdm#d+)&|O+XR&<2!Vx{>A z`mN$T&%F|ReVp!hyEl{L4*nZ+$cI*_QY$MtQCTym6<2umOkffplTZh8#JAFND6Lw0 zQ+-%LPgL^cy=tE+avw$0`I1h`c3NT&J8v+6&2+Gx9p}!{@a2lO!+ya@y!=l3DtU|D zvlcE#GUOb4T1g%9k?XIaij?xVBaSe$d}+(jcjB@7#!LJ-N73BL8$BssNH8*$-IsIG zF`~7 z?SJd2vR={y0QWoWt$17lXf>|Z`8JN!B!1sVI{Ff-mDDL-^Ac1_ELA>d)u87o)>TS< zd!!`a|KtgM&{lNHF7);uUo(W^9$5*LRib|~x+!+=`95nve5{us0bC+nByV*lwMgRr z(VhL6usjTCd-@2@;Sky2p*NCrq}atg9jW77t&{WvyfKr^GhW$>V!to;G;KK90#{M) zAC|@#!Lu1B-UnWsg!^(Zm$8q$4=im_M4-n3??T#~D{o71emo_9^r;Q*oCVRnK>6`D za2I&Kbg6a9qf#~Xe1IjAyRz0}6VH@frA9X;QtRjO$9m-QaAn<5?)Wn8Y3KyF?>}>( zXNtL&jQ!0m<|_V&`GC2}R<)Vt9^1yYF_Y{$_8jxD?atqAAF)00bMQb@4QkX1bWuvA$kxF6SGkt4%-t={d~YW?JI0>JFTi z-^qTOxtINPb3ac#!#u`*miZI=*=8#HIc5g`M(<`8njF*9EN1UzK4ibdtYg0vZwuSZ zW#(J<%S}F>F0L@R={8rIV!T6KWdk-~uF;xYYn$8V<_6o^wl+iUiS}gkGuzd6rJkqT zGt5YPwmsX7vfXSqbBjIKo@++i^ZA4HFYHCs_EvkPy~d2w+TNkHonXh?@#b!OyPW`y z?zQ)t`|Pjnug(4TF{to>{fm9WOtc@`HRdJzncZaO*)4XfdBYajVzb1-N4a?$N;QH~ zEumB=YTv?iH+PwK<{tLpN|6yzq`=%rZ4Q|Y#qZ9;wa+vDtU=74Q! zTbg3qj{3x?&-u2B;B9N#%WQ93%l2ijXUEuEZGAAl(>A27?xp@Dnr?8n)O;D5Ye z50n@1VRw=(urVlc$VsyoJLyik?GMJ!fpNO&3h&mU^=i|yXVcD2X~Smhmey@aT{@Z0 zv}6}^3hNzRxju`!^(5!T><;bMi+ABJp}o$bwXULVg)h3pdp|Sh!X4s4V7R%-oQEHQ zkx0(X@ZC^ieMBiyycb;hLAs%lr#=2LGS!2iMI5r}_Mcc7Sr=m* zh_b?iz0K=pDLLOZ%gy!X1NIA*E1R2-_<#Ix^RfBVj4*4>=jP|;3$ukfY-R7S+T%mJ_}W>1HQ4+8%X`-R43AE@pNZ?A%ER&XLI-mcx(Hb zeGRV$xi;4{LK+LuTl?*PGt3s+Le8T$%DHHiK}c#0dLD9u=4L14giL=Y)k)>N3VvyB zQhig;NpsRTN0QN+4zeJUY*mtjDz`zE+Z2`6ph{^_<+G{ECoqxERF%)JDxXzUK2KHo ztg7-ER{3<0&)bpkJCM?#%4a|&^AwfLph{*)B{QOu*+nI@CUW^F#wQ|^HB}}vR3=-h zOm@*;psh+{JEUAUKHBbuow8tEtpws?@c$j&QG*~?VftEsZrKxMC;%HCBf zdzYx}U8AzsM`iC~mA#8p_O4LbyG&*80+qd9wzjPUPt>(_;g5Q@9_RIKebdP{unjnG zXd9B=$TmW@vu!r~C$f4xdc1|{fd)U`G_$R2E8;e`Eix|>TT3Ojwn}VWmDt)Uv2|5e zTdAzJR#|POvf5f@^)!{$ES1$3Dyvy4tLLk%UZ=8pmdfe?mDTzxt7$5$r>m@XS6Mw- zW%UG=)e}@!kF#U>H~!gn9Dm0@N9DFHa(gFUI7C*{?S1&ZInzF1A27}Buk5dgMONFW ztkzIjZKJYULuIwT%4(X*>KQ7f{Z&3sQu*wp^4U=(v$M)%2bIICRSqvzIlNNku&>JD zwJL`_RStWr9A2(+c%jN+KkdgeR2u83G}cpTtfSIc&lV$%St^Y!kj7L~O(n3QN?>)B zz=kS;$Iv`%!uU| z$o_qPKJgWtg*W0Brj6-Hzbqqx-t?|R=o!bLA@29{i6`M&Na=B=tvQ)7!?}zV`k-@e zU?gxWJ^cfIKJm(1n>Q(rXROqTKL0#4RbP7Op=hJAXqyN9eBy<;j%mzz+)?AM8lR={c^dcB`0^VD-}Y_c!vCyJZIRL!8ZhQG+wK5p~SexmAH$> zS7|(6<0nVnIDBwQj>d0j{Eo(d*La1-Yc&2s<4q%PxnX3=c8&8i-m7tu#)l;iri>bQ z^O#^*y;ExQE8QH14hOH5w1pc<3!-hK>sUT;tIi zkJtDvjqlfZlE#l~{D)gvOEiA()*D9+3x1&S#~QEIc!S29 zHU3)TT^bkMdTaX*!D5L+M&ppiX&Ps0Tvy|4jav|R47Jg?y~bTMK11W~8egdKr5g7o zJ~`A+<3Sn^(|DxDV>G^9<9jrIkhpW`5sjbF_(_eQ(RixHFKIkW;{~^l8+~hNvBpa^ zUZ(L!8n4oLoyK2kymc(?722usUX6<-PIWX6Yn-WZeT|!;gDm>zhsE_Zf92y$&0pC# z9Stdes82;Z@)izyC=J~qf8MCBU&H@jzi-iM(jJwM$xi-&#@@N3=h6d7khta#jOkS< zRr$D{=C6F5t@$e-iv`509i(z`Bh6p=xS{5+eB40uS3Yj4`70mG`+-*960A(@&==OF z#L*pF(PMi(?06lAFE;Nm;$FqLdNVWGy;vR`#=4n|Q)SfI(VoWGuqT#>es+lcIU~La z%)5SPpSDvOh0U{z?K^4@a~SzOLLI1weL_b)mU)&~${ah5IggyxvG3Te%xI2xdN@O! z`<=y*9zagTr8!^^Y(4 z$FKe4cK_(Z3-0os7fM$+!BCS>$54+@ztAWzUucYfobKoI;f0p?>8t#s4>u$p+Noy< z_@P3**UV3ml`v@uY&aqDc5Xv_Pls;JA~l7Pro&i^Gpr5{b1;G8gjR&skZ*;SYwo8l z3oRvWSxH(Xw4{PPmpr+^@V<$(p(jlw^h^c&lbRzOnow>Z3xu&r5c--hbSb;{)$&zC zrDs1hs9~91G+V$_2_7!B7a1G$Y|2W>;0AJRF15?5Y`~-jmnH4cB)By3 zyx?^1&rI5@|U-C@c>_Cn3$r0h)DinMG^z)4wS zoRlw!1%^#|PlMd{%I9#M}tEleroZo6$o+nGrP`yZojbW?FPHierdn5o9sU= zy2UBtsk51V&!sPz&un`kv+UQHT`!_%cpbYrHRdTcknSC!!tubYVmdlCoJ^;tljYQM zYCCnDx=uZ(zSF>I=rnS&oyJZRr>WD-Y3a0bT03o=woW_e1m{HOB&WU8!RhFn>~wNE zJ6)VpoUZmwyTra_m)f`KUEa0t*}vIk_I>+zyWD;NeoLX}eth~#aGWKkDOQPl>@Tq( z-bX+70M^4_VUKtSJH*3S27iMM;@9?3tPl9~#MXnyTkJddyTzuH$%?`Cc90!RO{Uo_ zpiRR|bQPzn6DFkDZKO}dUgoAm|IY5RyX_vEZ}-}L>;>3F3bBP0*#p=@V)mfjX78eY zBLz$Bv)1`JSjYR7Uy^zW@Kdm;$(iHy#RB~T)|(fx+PutY;T0@4uVOvz!MzI^=?7R@ zc?SNT3{Ow7&oQES-cIA*879@KZnrqiofgh<&hb#iVh^AnW1mWJ%nW%t`wYG6Ys9;- zgH1xNEp`rK#l>Rfm-F+aG;Qk5N~~bO^$XZ>WfbKgO=woIK`u-7ppW?sEWeURORMz@ zkJ8}oUCWwTSXYrEq~haNo?)BYR-|6Rz3O_e5i;_IX#(sZ&t;8D?QmuDi;b^3rG{Y3 zm;5crpTaIHAF{%fYMTJpVCOpp`*}B3x2j>WJ0A(Wz+M2)iCx_*SyO8fB@SUV{fxD> z&#`Fk1BM0GV_NH=eHu}lvbP~q;yD@|{8sKtTPE5;))S;1q(<<$(p}C{2~*<~#tvBkKJtZ+gi)aO zxM^DHFu)9OJ~f>y470c=p-Ux0L-G{>t8DlO=>)9Qg9)|0BX6k5KLm?edoQLw#n=yv zv62O^1Zqq36lxbQvptVkPx|=%X!o{Y6lN6X0G?L&fG7Z7GwFJRmuAyf&XM`E7v}tOZ z2|WUDP%pu?hG|#nFqHbVGxR+bgrTHKXj8d>Hma}r>Y{W9Vk+$B5{c|H< z9m}^F8+nIiBQw2?yg#s!(L4Wmk$ZNB=`F#0NnS4;FUPpRUS?h?NtYsUsuF=WVb^b{Bv#IAiPr0`UJ4~jN zOWJzQKc=l0vVQn0*QMRhVrB2dz|UA!Y|DCKTT`D^&g%$m>`i{i1Lwo0RmzoE>1UbK zSbaSWt=)!jB7H#{f~3&~gr?>@tmxk%LyM?) z2G6R_it;oka7I&{INLIz9xBLKkQfxS#bok7gZ!H;XWc5XZF}=NAzK*Ixd)hwDIQ+#BGL zYmoLQBFqgdT)T3Fi{z zUPr>ogtH0F6tBwQe=a&fLS^e4&wmbWbQNuJHh4-uDE(JW!Uu%w3DU=ijut)S>9>nS zR?$NjsSXl(%|Q+cjGY-Pl$>`|-v5sG0G==}1E+0egWwVm$bD};ztlV-yauf2Z9B#c z*T8qzm|+B=RaZhgg3wrK*vmhjOx|+{%?Z5-EeQ?e7-(YJ2fjvT+VQ4CJJy-KqrmL# w?<0`J;bx46>o{-4irERQn>8@E&<`-Y(Or&)e!vTop0@guPOC5JtZ}FR2iDEk6aWAK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07778948b43b661a60531260c9bf6d31b2399033 GIT binary patch literal 139012 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

M!bpmUA$3Fj8)R_8Y7%g$Gwd!757uRC9J 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 dpG0bT{|^&vYghmP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..16985093d97eeea0680d7c54b4942378afe7c6ee GIT binary patch literal 131204 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 X6 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 apPQA6@7{W=)L3JRHk~wN(@8@W4*6fc&@$x! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..231c2b677483ac7c77cc68c5196bcda586d702ac GIT binary patch literal 134832 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≧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%0DU@ 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

r}RV*`Z};mt9eI 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

oP^#t8gPwhAf)=@7ev0w58R!SCh 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®T}@vM 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9f92725889f1f6bb61bc56ca904bb85e197068fa GIT binary patch literal 98260 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

6ZPa3<7YQBb@exyE1l*qpKDxF71yc8>_mewp8k8 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dbe2cefa5b21a8537cef9b9d0972e759de68b373 GIT binary patch literal 126550 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 XsVG3B06DyiS+X?vDYt@IuX@~m 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9a8e0158a7b145fed52657276d2ff364aeb1fdae GIT binary patch literal 730105 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⁡)7mjtH)VGvC-u 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 nj`t*;eKVSqU~p>B+0wL6e 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 + + + + + + + + + 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 ( +

+ ) +} + +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 ( +
+
+