From e716c4075726416b816ab06dd2faddccd77b5d45 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 15 Jun 2023 08:57:19 +0300 Subject: [PATCH 1/9] #RI-4530 redactor redis-cloud module to be a part of cloud autodiscovery module inside cloud parent module --- .../api/src/__mocks__/cloud-autodiscovery.ts | 184 +++++++ redisinsight/api/src/__mocks__/index.ts | 1 + .../api/src/__mocks__/redis-enterprise.ts | 26 - redisinsight/api/src/app.module.ts | 2 + .../cloud-autodicovery.analytics.spec.ts | 214 ++++++++ .../cloud-autodiscovery.analytics.ts | 57 ++ .../cloud-autodiscovery.service.spec.ts | 256 +++++++++ .../cloud-autodiscovery.service.ts} | 370 +++++-------- .../cloud.autodiscovery.controller.ts} | 63 +-- .../cloud.autodiscovery.module.ts | 13 + .../dto/add-cloud-database.dto.ts | 22 + .../dto/add-cloud-database.response.ts | 41 ++ .../dto/add-cloud-databases.dto.ts | 21 + .../cloud/autodiscovery/dto/cloud-auth.dto.ts | 22 + .../dto/get-cloud-databases.dto.ts | 22 + .../get-cloud-subscription-database.dto.ts | 26 + .../get-cloud-subscription-databases.dto.ts | 16 + .../modules/cloud/autodiscovery/dto/index.ts | 7 + .../models/cloud-account-info.ts | 27 + .../models/cloud-api.interface.ts | 137 +++++ .../autodiscovery/models/cloud-database.ts | 86 +++ .../models/cloud-subscription.ts | 48 ++ .../cloud/autodiscovery/models/index.ts | 4 + .../utils/redis-cloud-converter.spec.ts | 2 +- .../utils/redis-cloud-converter.ts | 118 +++++ .../api/src/modules/cloud/cloud.module.ts | 12 + .../modules/redis-enterprise/dto/cloud.dto.ts | 203 ------- .../dto/redis-enterprise-cloud.dto.ts | 87 --- .../models/redis-cloud-account.ts | 22 - .../models/redis-cloud-database.ts | 87 --- .../models/redis-cloud-subscriptions.ts | 48 -- .../models/redis-enterprise-database.ts | 9 + .../redis-cloud.service.spec.ts | 494 ------------------ .../redis-enterprise.analytics.spec.ts | 184 ------- .../redis-enterprise.analytics.ts | 42 -- .../redis-enterprise.module.ts | 5 +- .../redis-enterprise.service.spec.ts | 14 +- .../redis-enterprise.service.ts | 16 +- .../utils/redis-cloud-converter.ts | 5 - ...T-cloud-autodiscovery-get_account.test.ts} | 57 +- ...cloud-autodiscovery-get_databases.test.ts} | 62 ++- ...d-autodiscovery-get_subscriptions.test.ts} | 63 ++- redisinsight/api/test/api/deps.ts | 6 + redisinsight/api/test/helpers/constants.ts | 6 +- redisinsight/api/test/helpers/test.ts | 3 +- 45 files changed, 1650 insertions(+), 1560 deletions(-) create mode 100644 redisinsight/api/src/__mocks__/cloud-autodiscovery.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts rename redisinsight/api/src/modules/{redis-enterprise/redis-cloud.service.ts => cloud/autodiscovery/cloud-autodiscovery.service.ts} (50%) rename redisinsight/api/src/modules/{redis-enterprise/controllers/cloud.controller.ts => cloud/autodiscovery/cloud.autodiscovery.controller.ts} (58%) create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.module.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.response.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/dto/index.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-account-info.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts rename redisinsight/api/src/modules/{redis-enterprise => cloud/autodiscovery}/utils/redis-cloud-converter.spec.ts (82%) create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts create mode 100644 redisinsight/api/src/modules/cloud/cloud.module.ts delete mode 100644 redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts delete mode 100644 redisinsight/api/src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto.ts delete mode 100644 redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts delete mode 100644 redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-database.ts delete mode 100644 redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-subscriptions.ts delete mode 100644 redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.spec.ts delete mode 100644 redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.ts rename redisinsight/api/test/api/cloud/{POST-redis_enterprise-cloud-get_account.test.ts => POST-cloud-autodiscovery-get_account.test.ts} (58%) rename redisinsight/api/test/api/cloud/{POST-redis_enterprise-cloud-get_databases.test.ts => POST-cloud-autodiscovery-get_databases.test.ts} (63%) rename redisinsight/api/test/api/cloud/{POST-redis_enterprise-cloud-get_subscriptions.test.ts => POST-cloud-autodiscovery-get_subscriptions.test.ts} (57%) diff --git a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts new file mode 100644 index 0000000000..56077cca4c --- /dev/null +++ b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts @@ -0,0 +1,184 @@ +import { + CloudAccountInfo, + CloudDatabase, CloudDatabaseProtocol, + CloudDatabaseStatus, + CloudSubscription, + CloudSubscriptionStatus, ICloudApiAccount, ICloudApiDatabase, ICloudApiSubscription, +} from 'src/modules/cloud/autodiscovery/models'; +import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto'; + +export const mockCloudApiAccount: ICloudApiAccount = { + 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', + }, +}; + +export const mockCloudAccountInfo = Object.assign(new CloudAccountInfo(), { + accountId: mockCloudApiAccount.id, + accountName: mockCloudApiAccount.name, + ownerEmail: mockCloudApiAccount.key.owner.email, + ownerName: mockCloudApiAccount.key.owner.name, +}); + +export const mockCloudApiSubscription: ICloudApiSubscription = { + id: 108353, + name: 'external CA', + status: CloudSubscriptionStatus.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, + }, + ], + }, + ], +}; + +export const mockCloudSubscription = Object.assign(new CloudSubscription(), { + id: mockCloudApiSubscription.id, + name: mockCloudApiSubscription.name, + numberOfDatabases: mockCloudApiSubscription.numberOfDatabases, + provider: mockCloudApiSubscription.cloudDetails[0].provider, + region: mockCloudApiSubscription.cloudDetails[0].regions[0].region, + status: mockCloudApiSubscription.status, +}); + +export const mockCloudApiDatabase: ICloudApiDatabase = { + databaseId: 50859754, + name: 'bdb', + protocol: CloudDatabaseProtocol.Redis, + provider: 'GCP', + region: 'us-central1', + redisVersionCompliance: '5.0.5', + status: CloudDatabaseStatus.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: [], +}; + +export const mockCloudDatabase = Object.assign(new CloudDatabase(), { + subscriptionId: mockCloudSubscription.id, + databaseId: mockCloudApiDatabase.databaseId, + name: mockCloudApiDatabase.name, + publicEndpoint: mockCloudApiDatabase.publicEndpoint, + status: mockCloudApiDatabase.status, + sslClientAuthentication: false, + modules: ['ReJSON'], + options: { + enabledBackup: false, + enabledClustering: false, + enabledDataPersistence: false, + enabledRedisFlash: false, + enabledReplication: true, + isReplicaDestination: true, + persistencePolicy: 'none', + }, +}); + +export const mockCloudDatabaseFromList = Object.assign(new CloudDatabase(), { + ...mockCloudDatabase, + options: { + ...mockCloudDatabase.options, + isReplicaSource: false, + }, +}); + +export const mockCloudApiDatabases = { + accountId: mockCloudAccountInfo.accountId, + subscription: [ + { + subscriptionId: mockCloudSubscription.id, + numberOfDatabases: mockCloudSubscription.numberOfDatabases, + databases: [mockCloudApiDatabase], + }, + ], +}; + +export const mockCloudAuthDto: CloudAuthDto = { + apiKey: 'api_key', + apiSecretKey: 'api_secret_key', +}; + +export const mockCloudAutodiscoveryAnalytics = jest.fn(() => ({ + sendGetRECloudSubsSucceedEvent: jest.fn(), + sendGetRECloudSubsFailedEvent: jest.fn(), + sendGetRECloudDbsSucceedEvent: jest.fn(), + sendGetRECloudDbsFailedEvent: jest.fn(), +})); diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index 4298564992..14782b0e09 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -23,3 +23,4 @@ export * from './ssh'; export * from './browser-history'; export * from './database-recommendation'; export * from './feature'; +export * from './cloud-autodiscovery'; diff --git a/redisinsight/api/src/__mocks__/redis-enterprise.ts b/redisinsight/api/src/__mocks__/redis-enterprise.ts index 93c7b274f3..58488cb87b 100644 --- a/redisinsight/api/src/__mocks__/redis-enterprise.ts +++ b/redisinsight/api/src/__mocks__/redis-enterprise.ts @@ -1,7 +1,5 @@ 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'; export const mockRedisEnterpriseDatabaseDto: RedisEnterpriseDatabase = { uid: 1, @@ -16,31 +14,7 @@ export const mockRedisEnterpriseDatabaseDto: RedisEnterpriseDatabase = { 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 mockRedisEnterpriseAnalytics = jest.fn(() => ({ sendGetREClusterDbsSucceedEvent: jest.fn(), sendGetREClusterDbsFailedEvent: jest.fn(), - sendGetRECloudSubsSucceedEvent: jest.fn(), - sendGetRECloudSubsFailedEvent: jest.fn(), - sendGetRECloudDbsSucceedEvent: jest.fn(), - sendGetRECloudDbsFailedEvent: jest.fn(), })); diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index 4a4fd36a07..d89dd4a734 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -22,6 +22,7 @@ import { AutodiscoveryModule } from 'src/modules/autodiscovery/autodiscovery.mod import { DatabaseImportModule } from 'src/modules/database-import/database-import.module'; import { DummyAuthMiddleware } from 'src/common/middlewares/dummy-auth.middleware'; import { CustomTutorialModule } from 'src/modules/custom-tutorial/custom-tutorial.module'; +import { CloudModule } from 'src/modules/cloud/cloud.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'; @@ -42,6 +43,7 @@ const PATH_CONFIG = config.get('dir_path'); RouterModule.forRoutes(routes), AutodiscoveryModule, RedisEnterpriseModule, + CloudModule.register(), RedisSentinelModule, BrowserModule, CliModule, diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts new file mode 100644 index 0000000000..1a50faa9c9 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts @@ -0,0 +1,214 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { InternalServerErrorException } from '@nestjs/common'; +import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; +import { CloudDatabaseStatus, CloudSubscriptionStatus } from 'src/modules/cloud/autodiscovery/models'; +import { mockCloudDatabase, mockCloudSubscription } from 'src/__mocks__'; + +describe('CloudAutodiscoveryAnalytics', () => { + let service: CloudAutodiscoveryAnalytics; + let sendEventMethod; + let sendFailedEventMethod; + const httpException = new InternalServerErrorException(); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + CloudAutodiscoveryAnalytics, + ], + }).compile(); + + service = await module.get(CloudAutodiscoveryAnalytics); + sendEventMethod = jest.spyOn( + service, + 'sendEvent', + ); + sendFailedEventMethod = jest.spyOn( + service, + 'sendFailedEvent', + ); + }); + + describe('sendGetRECloudSubsSucceedEvent', () => { + it('should emit event with active subscriptions', () => { + service.sendGetRECloudSubsSucceedEvent([ + mockCloudSubscription, + mockCloudSubscription, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 2, + totalNumberOfSubscriptions: 2, + }, + ); + }); + it('should emit event with active and not active subscription', () => { + service.sendGetRECloudSubsSucceedEvent([ + { + ...mockCloudSubscription, + status: CloudSubscriptionStatus.Error, + }, + mockCloudSubscription, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: 1, + totalNumberOfSubscriptions: 2, + }, + ); + }); + it('should emit event without active subscriptions', () => { + service.sendGetRECloudSubsSucceedEvent([ + { + ...mockCloudSubscription, + status: CloudSubscriptionStatus.Error, + }, + { + ...mockCloudSubscription, + status: CloudSubscriptionStatus.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([ + mockCloudDatabase, + mockCloudDatabase, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 2, + totalNumberOfDatabases: 2, + }, + ); + }); + it('should emit event with active and not active database', () => { + service.sendGetRECloudDbsSucceedEvent([ + { + ...mockCloudDatabase, + status: CloudDatabaseStatus.Pending, + }, + mockCloudDatabase, + ]); + + expect(sendEventMethod).toHaveBeenCalledWith( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: 1, + totalNumberOfDatabases: 2, + }, + ); + }); + it('should emit event without active databases', () => { + service.sendGetRECloudDbsSucceedEvent([ + { + ...mockCloudDatabase, + status: CloudDatabaseStatus.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, + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts new file mode 100644 index 0000000000..d9075b0d8c --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts @@ -0,0 +1,57 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { TelemetryBaseService } from 'src/modules/analytics/telemetry.base.service'; +import { + CloudDatabase, + CloudDatabaseStatus, + CloudSubscription, + CloudSubscriptionStatus, +} from 'src/modules/cloud/autodiscovery/models'; + +@Injectable() +export class CloudAutodiscoveryAnalytics extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendGetRECloudSubsSucceedEvent(subscriptions: CloudSubscription[] = []) { + try { + this.sendEvent( + TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, + { + numberOfActiveSubscriptions: subscriptions.filter( + (sub) => sub.status === CloudSubscriptionStatus.Active, + ).length, + totalNumberOfSubscriptions: subscriptions.length, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendGetRECloudSubsFailedEvent(exception: HttpException) { + this.sendFailedEvent(TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, exception); + } + + sendGetRECloudDbsSucceedEvent(databases: CloudDatabase[] = []) { + try { + this.sendEvent( + TelemetryEvents.RECloudDatabasesDiscoverySucceed, + { + numberOfActiveDatabases: databases.filter( + (db) => db.status === CloudDatabaseStatus.Active, + ).length, + totalNumberOfDatabases: databases.length, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendGetRECloudDbsFailedEvent(exception: HttpException) { + this.sendFailedEvent(TelemetryEvents.RECloudDatabasesDiscoveryFailed, exception); + } +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts new file mode 100644 index 0000000000..77005c1e24 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts @@ -0,0 +1,256 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import axios, { AxiosError } from 'axios'; +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { + mockCloudAccountInfo, mockCloudApiAccount, mockCloudApiDatabase, mockCloudApiDatabases, mockCloudApiSubscription, + mockCloudAuthDto, mockCloudAutodiscoveryAnalytics, mockCloudDatabase, mockCloudDatabaseFromList, + mockCloudSubscription, mockDatabaseService, MockType, +} from 'src/__mocks__'; +import { DatabaseService } from 'src/modules/database/database.service'; +import { CloudAutodiscoveryService } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.service'; +import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; + +const mockedAxios = axios as jest.Mocked; +jest.mock('axios'); +mockedAxios.create = jest.fn(() => mockedAxios); + +const mockUnauthenticatedErrorMessage = 'Request failed with status code 401'; +const mockApiUnauthenticatedResponse = { + message: mockUnauthenticatedErrorMessage, + response: { + status: 401, + }, +}; + +describe('CloudAutodiscoveryService', () => { + let service: CloudAutodiscoveryService; + let analytics: MockType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CloudAutodiscoveryService, + { + provide: DatabaseService, + useFactory: mockDatabaseService, + }, + { + provide: CloudAutodiscoveryAnalytics, + useFactory: mockCloudAutodiscoveryAnalytics, + }, + ], + }).compile(); + + service = module.get(CloudAutodiscoveryService); + analytics = module.get(CloudAutodiscoveryAnalytics); + }); + + describe('getAccount', () => { + it('successfully get Redis Enterprise Cloud account', async () => { + const response = { + status: 200, + data: { account: mockCloudApiAccount }, + }; + mockedAxios.get.mockResolvedValue(response); + + expect(await service.getAccount(mockCloudAuthDto)).toEqual(mockCloudAccountInfo); + }); + it('Should throw Forbidden exception', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect(service.getAccount(mockCloudAuthDto)).rejects.toThrow( + ForbiddenException, + ); + }); + }); + + describe('getSubscriptions', () => { + it('successfully get Redis Enterprise Cloud subscriptions', async () => { + const response = { + status: 200, + data: { subscriptions: [mockCloudApiSubscription] }, + }; + mockedAxios.get.mockResolvedValue(response); + + expect(await service.getSubscriptions(mockCloudAuthDto)).toEqual([mockCloudSubscription]); + expect(analytics.sendGetRECloudSubsSucceedEvent).toHaveBeenCalledWith([mockCloudSubscription]); + }); + it('should throw forbidden error when get subscriptions', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect(service.getSubscriptions(mockCloudAuthDto)).rejects.toThrow( + ForbiddenException, + ); + + expect(analytics.sendGetRECloudSubsFailedEvent) + .toHaveBeenCalledWith(service['getApiError']( + mockApiUnauthenticatedResponse as AxiosError, + 'Failed to get RE cloud subscriptions', + )); + }); + }); + + describe('getSubscriptionDatabase', () => { + it('successfully get database from Redis Cloud subscriptions', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockCloudApiDatabase, + }); + + expect(await service.getSubscriptionDatabase({ + ...mockCloudAuthDto, + subscriptionId: mockCloudSubscription.id, + databaseId: mockCloudDatabase.databaseId, + })).toEqual(mockCloudDatabase); + }); + it('the user could not be authenticated', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect( + service.getSubscriptionDatabase({ + ...mockCloudAuthDto, + subscriptionId: mockCloudSubscription.id, + databaseId: mockCloudDatabase.databaseId, + }), + ).rejects.toThrow(ForbiddenException); + }); + it('database not found', async () => { + const apiResponse = { + message: `Subscription ${mockCloudSubscription.id} database ${mockCloudDatabase.databaseId} not found`, + response: { + status: 404, + }, + }; + mockedAxios.get.mockRejectedValue(apiResponse); + + await expect( + service.getSubscriptionDatabase({ + ...mockCloudAuthDto, + subscriptionId: mockCloudSubscription.id, + databaseId: mockCloudDatabase.databaseId, + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getSubscriptionDatabases', () => { + it('successfully get Redis Enterprise Cloud databases', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockCloudApiDatabases, + }); + + expect(await service.getSubscriptionDatabases({ + ...mockCloudAuthDto, + subscriptionId: mockCloudSubscription.id, + })).toEqual([mockCloudDatabaseFromList]); + }); + it('the user could not be authenticated', async () => { + mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); + + await expect(service.getSubscriptionDatabases({ + ...mockCloudAuthDto, + subscriptionId: mockCloudSubscription.id, + })).rejects.toThrow(ForbiddenException); + }); + it('subscription not found', async () => { + mockedAxios.get.mockRejectedValue({ + message: `Subscription ${mockCloudSubscription.id} not found`, + response: { + status: 404, + }, + }); + + await expect(service.getSubscriptionDatabases({ + ...mockCloudAuthDto, + subscriptionId: mockCloudSubscription.id, + })).rejects.toThrow(NotFoundException); + }); + }); + + describe('getDatabasesInMultipleSubscriptions', () => { + beforeEach(() => { + service.getSubscriptionDatabases = jest.fn().mockResolvedValue([]); + }); + it('should call getDatabasesInSubscription', async () => { + await service.getDatabases({ + ...mockCloudAuthDto, + subscriptionIds: [86070, 86071], + }); + + expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); + }); + it('should not call getDatabasesInSubscription for duplicated ids', async () => { + await service.getDatabases({ + ...mockCloudAuthDto, + subscriptionIds: [86070, 86070, 86071], + }); + + expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); + }); + it('subscription not found', async () => { + service.getSubscriptionDatabases = jest + .fn() + .mockRejectedValue(new NotFoundException()); + + await expect( + service.getDatabases({ + ...mockCloudAuthDto, + subscriptionIds: [86070, 86071], + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + 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/redis-enterprise/redis-cloud.service.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts similarity index 50% rename from redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.ts rename to redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts index ce8b8766f1..4225e8d01e 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts @@ -7,42 +7,37 @@ import { NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import axios, { AxiosError, AxiosResponse } from 'axios'; -import { get, find, uniq } from 'lodash'; +import { 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 { + AddCloudDatabaseDto, + AddCloudDatabaseResponse, 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'; + GetCloudDatabasesDto, + GetCloudSubscriptionDatabaseDto, + GetCloudSubscriptionDatabasesDto, +} from 'src/modules/cloud/autodiscovery/dto'; 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 { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; -import { - AddRedisCloudDatabaseDto, - AddRedisCloudDatabaseResponse, -} from 'src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto'; -import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; + CloudAccountInfo, + CloudDatabase, + CloudDatabaseStatus, + CloudSubscription, +} from 'src/modules/cloud/autodiscovery/models'; import { DatabaseService } from 'src/modules/database/database.service'; import { HostingProvider } from 'src/modules/database/entities/database.entity'; import { ActionStatus } from 'src/common/models'; +import { + parseCloudAccountResponse, + parseCloudDatabaseResponse, + parseCloudSubscriptionsResponse, + parseCloudDatabasesInSubscriptionResponse, +} from 'src/modules/cloud/autodiscovery/utils/redis-cloud-converter'; +import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; @Injectable() -export class RedisCloudService { - private logger = new Logger('RedisCloudBusinessService'); +export class CloudAutodiscoveryService { + private logger = new Logger('CloudAutodiscoveryService'); private config = config.get('redis_cloud'); @@ -50,31 +45,81 @@ export class RedisCloudService { constructor( private readonly databaseService: DatabaseService, - private readonly analytics: RedisEnterpriseAnalytics, + private readonly analytics: CloudAutodiscoveryAnalytics, ) {} - async getAccount( - dto: CloudAuthDto, - ): Promise { - this.logger.log('Getting RE cloud account.'); + /** + * Generates auth headers to attach to the request + * @param apiKey + * @param apiSecretKey + * @private + */ + static getAuthHeaders(apiKey: string, apiSecretKey: string) { + return { + 'x-api-key': apiKey, + 'x-api-secret-key': apiSecretKey, + }; + } + + /** + * Generates proper error based on api response + * @param error + * @param errorTitle + * @private + */ + private 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); + } + + /** + * Get cloud account short info + * @param dto + */ + async getAccount(dto: CloudAuthDto): Promise { + this.logger.log('Getting cloud account.'); const { apiKey, apiSecretKey } = dto; try { const { data: { account }, }: AxiosResponse = await this.api.get(`${this.config.url}/`, { - headers: this.getAuthHeaders(apiKey, apiSecretKey), + headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecretKey), }); + this.logger.log('Succeed to get RE cloud account.'); - return this.parseCloudAccountResponse(account); + return parseCloudAccountResponse(account); } catch (error) { throw this.getApiError(error, 'Failed to get RE cloud account'); } } - async getSubscriptions( - dto: CloudAuthDto, - ): Promise { + /** + * Get list of account subscriptions + * @param dto + */ + async getSubscriptions(dto: CloudAuthDto): Promise { this.logger.log('Getting RE cloud subscriptions.'); const { apiKey, apiSecretKey } = dto; try { @@ -83,11 +128,11 @@ export class RedisCloudService { }: AxiosResponse = await this.api.get( `${this.config.url}/subscriptions`, { - headers: this.getAuthHeaders(apiKey, apiSecretKey), + headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecretKey), }, ); this.logger.log('Succeed to get RE cloud subscriptions.'); - const result = this.parseCloudSubscriptionsResponse(subscriptions); + const result = parseCloudSubscriptionsResponse(subscriptions); this.analytics.sendGetRECloudSubsSucceedEvent(result); return result; } catch (error) { @@ -97,77 +142,83 @@ export class RedisCloudService { } } - async getDatabasesInSubscription( - dto: GetDatabasesInCloudSubscriptionDto, - ): Promise { - const { apiKey, apiSecretKey, subscriptionId } = dto; + /** + * Get single database details + * @param dto + */ + async getSubscriptionDatabase(dto: GetCloudSubscriptionDatabaseDto): Promise { + const { + apiKey, apiSecretKey, subscriptionId, databaseId, + } = dto; this.logger.log( - `Getting databases in RE cloud subscription. subscription id: ${subscriptionId}`, + `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`, + `${this.config.url}/subscriptions/${subscriptionId}/databases/${databaseId}`, { - headers: this.getAuthHeaders(apiKey, apiSecretKey), + headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecretKey), }, ); this.logger.log('Succeed to get databases in RE cloud subscription.'); - return this.parseCloudDatabasesInSubscriptionResponse(data); + return parseCloudDatabaseResponse(data, subscriptionId); } 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', + `Failed to get databases in RE cloud subscription. ${response?.data?.message}.`, ); + throw new NotFoundException(response?.data?.message); } - throw exception; + throw this.getApiError( + error, + 'Failed to get databases in RE cloud subscription', + ); } } - async getDatabase( - dto: GetDatabaseInCloudSubscriptionDto, - ): Promise { - const { - apiKey, apiSecretKey, subscriptionId, databaseId, - } = dto; + /** + * Get list of databases for subscription + * @param dto + */ + async getSubscriptionDatabases(dto: GetCloudSubscriptionDatabasesDto): Promise { + const { apiKey, apiSecretKey, subscriptionId } = dto; this.logger.log( - `Getting database in RE cloud subscription. subscription id: ${subscriptionId}, database id: ${databaseId}`, + `Getting databases in RE cloud subscription. subscription id: ${subscriptionId}`, ); try { const { data }: AxiosResponse = await this.api.get( - `${this.config.url}/subscriptions/${subscriptionId}/databases/${databaseId}`, + `${this.config.url}/subscriptions/${subscriptionId}/databases`, { - headers: this.getAuthHeaders(apiKey, apiSecretKey), + headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecretKey), }, ); this.logger.log('Succeed to get databases in RE cloud subscription.'); - return this.parseCloudDatabaseResponse(data, subscriptionId); + return 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. ${response?.data?.message}.`, + `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 new NotFoundException(response?.data?.message); } - throw this.getApiError( - error, - 'Failed to get databases in RE cloud subscription', - ); + throw exception; } } - async getDatabasesInMultipleSubscriptions( - dto: GetDatabasesInMultipleCloudSubscriptionsDto, - ): Promise { + /** + * Get get all databases from specified multiple subscriptions + * @param dto + */ + async getDatabases(dto: GetCloudDatabasesDto): Promise { const { apiKey, apiSecretKey } = dto; const subscriptionIds = uniq(dto.subscriptionIds); this.logger.log('Getting databases in RE cloud subscriptions.'); @@ -175,7 +226,7 @@ export class RedisCloudService { try { await Promise.all( subscriptionIds.map(async (subscriptionId: number) => { - const databases = await this.getDatabasesInSubscription({ + const databases = await this.getSubscriptionDatabases({ apiKey, apiSecretKey, subscriptionId, @@ -191,170 +242,19 @@ export class RedisCloudService { } } - 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) - )); - }); - } - - public async addRedisCloudDatabases( + async addRedisCloudDatabases( auth: CloudAuthDto, - addDatabasesDto: AddRedisCloudDatabaseDto[], - ): Promise { + addDatabasesDto: AddCloudDatabaseDto[], + ): Promise { this.logger.log('Adding Redis Cloud databases.'); - let result: AddRedisCloudDatabaseResponse[]; + let result: AddCloudDatabaseResponse[]; try { result = await Promise.all( addDatabasesDto.map( async ( - dto: AddRedisCloudDatabaseDto, - ): Promise => { - const database = await this.getDatabase({ + dto: AddCloudDatabaseResponse, + ): Promise => { + const database = await this.getSubscriptionDatabase({ ...auth, ...dto, }); @@ -362,7 +262,7 @@ export class RedisCloudService { const { publicEndpoint, name, password, status, } = database; - if (status !== RedisEnterpriseDatabaseStatus.Active) { + if (status !== CloudDatabaseStatus.Active) { const exception = new ServiceUnavailableException(ERROR_MESSAGES.DATABASE_IS_INACTIVE); return { ...dto, diff --git a/redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts similarity index 58% rename from redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts rename to redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts index 2975245896..fa3b386bc2 100644 --- a/redisinsight/api/src/modules/redis-enterprise/controllers/cloud.controller.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts @@ -9,29 +9,24 @@ import { } from '@nestjs/common'; import { TimeoutInterceptor } from 'src/common/interceptors/timeout.interceptor'; import { ApiTags } from '@nestjs/swagger'; -import { RedisEnterpriseDatabase } from 'src/modules/redis-enterprise/dto/cluster.dto'; -import { - CloudAuthDto, - GetCloudAccountShortInfoResponse, - GetDatabasesInMultipleCloudSubscriptionsDto, - GetRedisCloudSubscriptionResponse, - RedisCloudDatabase, -} from 'src/modules/redis-enterprise/dto/cloud.dto'; +import { CloudAccountInfo, CloudDatabase, CloudSubscription } from 'src/modules/cloud/autodiscovery/models'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; -import { RedisCloudService } from 'src/modules/redis-enterprise/redis-cloud.service'; -import { - AddMultipleRedisCloudDatabasesDto, - AddRedisCloudDatabaseResponse, -} from 'src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto'; import { Response } from 'express'; import { ActionStatus } from 'src/common/models'; import { BuildType } from 'src/modules/server/models/server'; +import { CloudAutodiscoveryService } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.service'; +import { + AddCloudDatabaseResponse, + AddCloudDatabasesDto, + CloudAuthDto, + GetCloudDatabasesDto, +} from 'src/modules/cloud/autodiscovery/dto'; -@ApiTags('Redis Enterprise Cloud') +@ApiTags('Cloud Autodiscovery') @UsePipes(new ValidationPipe({ transform: true })) -@Controller('redis-enterprise/cloud') -export class CloudController { - constructor(private redisCloudService: RedisCloudService) {} +@Controller('cloud/autodiscovery') +export class CloudAutodiscoveryController { + constructor(private service: CloudAutodiscoveryService) {} @Post('get-account') @UseInterceptors(new TimeoutInterceptor()) @@ -43,14 +38,12 @@ export class CloudController { { status: 200, description: 'Account Details.', - type: RedisEnterpriseDatabase, + type: CloudAccountInfo, }, ], }) - async getAccount( - @Body() dto: CloudAuthDto, - ): Promise { - return await this.redisCloudService.getAccount(dto); + async getAccount(@Body() dto: CloudAuthDto): Promise { + return await this.service.getAccount(dto); } @Post('get-subscriptions') @@ -63,15 +56,13 @@ export class CloudController { { status: 200, description: 'Redis cloud subscription list.', - type: GetRedisCloudSubscriptionResponse, + type: CloudSubscription, isArray: true, }, ], }) - async getSubscriptions( - @Body() dto: CloudAuthDto, - ): Promise { - return await this.redisCloudService.getSubscriptions(dto); + async getSubscriptions(@Body() dto: CloudAuthDto): Promise { + return await this.service.getSubscriptions(dto); } @Post('get-databases') @@ -84,17 +75,13 @@ export class CloudController { { status: 200, description: 'Databases list.', - type: RedisCloudDatabase, + type: CloudDatabase, isArray: true, }, ], }) - async getDatabases( - @Body() dto: GetDatabasesInMultipleCloudSubscriptionsDto, - ): Promise { - return await this.redisCloudService.getDatabasesInMultipleSubscriptions( - dto, - ); + async getDatabases(@Body() dto: GetCloudDatabasesDto): Promise { + return await this.service.getDatabases(dto); } @Post('databases') @@ -106,23 +93,23 @@ export class CloudController { { status: 201, description: 'Added databases list.', - type: AddRedisCloudDatabaseResponse, + type: AddCloudDatabaseResponse, isArray: true, }, ], }) @UsePipes(new ValidationPipe({ transform: true })) async addRedisCloudDatabases( - @Body() dto: AddMultipleRedisCloudDatabasesDto, + @Body() dto: AddCloudDatabasesDto, @Res() res: Response, ): Promise { const { databases, ...connectionDetails } = dto; - const result = await this.redisCloudService.addRedisCloudDatabases( + const result = await this.service.addRedisCloudDatabases( connectionDetails, databases, ); const hasSuccessResult = result.some( - (addResponse: AddRedisCloudDatabaseResponse) => addResponse.status === ActionStatus.Success, + (addResponse: AddCloudDatabaseResponse) => addResponse.status === ActionStatus.Success, ); if (!hasSuccessResult) { return res.status(200).json(result); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.module.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.module.ts new file mode 100644 index 0000000000..44b243ccb7 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { CloudAutodiscoveryController } from 'src/modules/cloud/autodiscovery/cloud.autodiscovery.controller'; +import { CloudAutodiscoveryService } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.service'; +import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; + +@Module({ + controllers: [CloudAutodiscoveryController], + providers: [ + CloudAutodiscoveryService, + CloudAutodiscoveryAnalytics, + ], +}) +export class CloudAutodiscoveryModule {} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts new file mode 100644 index 0000000000..47447df006 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsInt, IsNotEmpty } from 'class-validator'; + +export class AddCloudDatabaseDto { + @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; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.response.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.response.ts new file mode 100644 index 0000000000..7f7357b74b --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.response.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ActionStatus } from 'src/common/models'; +import { CloudDatabase } from 'src/modules/cloud/autodiscovery/models'; + +export class AddCloudDatabaseResponse { + @ApiProperty({ + description: 'Subscription id', + type: Number, + }) + subscriptionId: number; + + @ApiProperty({ + description: 'Database id', + type: Number, + }) + databaseId: number; + + @ApiProperty({ + description: 'Add Redis Cloud database status', + default: ActionStatus.Success, + enum: ActionStatus, + }) + status: ActionStatus; + + @ApiProperty({ + description: 'Message', + type: String, + }) + message: string; + + @ApiPropertyOptional({ + description: 'The database details.', + type: CloudDatabase, + }) + databaseDetails?: CloudDatabase; + + @ApiPropertyOptional({ + description: 'Error', + }) + error?: string | object; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts new file mode 100644 index 0000000000..e4622785ff --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayNotEmpty, IsArray, IsDefined, ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto/cloud-auth.dto'; +import { AddCloudDatabaseDto } from 'src/modules/cloud/autodiscovery/dto/add-cloud-database.dto'; + +export class AddCloudDatabasesDto extends CloudAuthDto { + @ApiProperty({ + description: 'Cloud databases list.', + type: AddCloudDatabaseDto, + isArray: true, + }) + @IsDefined() + @IsArray() + @ArrayNotEmpty() + @ValidateNested() + @Type(() => AddCloudDatabaseDto) + databases: AddCloudDatabaseDto[]; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts new file mode 100644 index 0000000000..0419336926 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsNotEmpty, IsString } from 'class-validator'; + +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; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts new file mode 100644 index 0000000000..a38231971d --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsInt } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto/cloud-auth.dto'; + +export class GetCloudDatabasesDto 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[]; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts new file mode 100644 index 0000000000..6981fd8314 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsInt, IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; +import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto/cloud-auth.dto'; + +export class GetCloudSubscriptionDatabaseDto 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; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts new file mode 100644 index 0000000000..9d6e50c878 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsInt, IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; +import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto/cloud-auth.dto'; + +export class GetCloudSubscriptionDatabasesDto extends CloudAuthDto { + @ApiProperty({ + description: 'Subscription Id', + type: Number, + }) + @IsDefined() + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + subscriptionId: number; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/index.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/index.ts new file mode 100644 index 0000000000..143033e141 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/index.ts @@ -0,0 +1,7 @@ +export * from './add-cloud-database.dto'; +export * from './add-cloud-database.response'; +export * from './add-cloud-databases.dto'; +export * from './cloud-auth.dto'; +export * from './get-cloud-subscription-database.dto'; +export * from './get-cloud-subscription-databases.dto'; +export * from './get-cloud-databases.dto'; diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-account-info.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-account-info.ts new file mode 100644 index 0000000000..37f01f842b --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-account-info.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CloudAccountInfo { + @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; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts new file mode 100644 index 0000000000..8bb02a92e4 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts @@ -0,0 +1,137 @@ +import { CloudDatabaseProtocol, CloudDatabaseStatus } from 'src/modules/cloud/autodiscovery/models/cloud-database'; +import { CloudSubscriptionStatus } from 'src/modules/cloud/autodiscovery/models/cloud-subscription'; + +// common interfaces +interface ICloudApiAlert { + name: string; + value: number; +} + +// Database interfaces +interface ICloudApiDatabaseClustering { + numberOfShards: number; + regexRules: any[]; + hashingPolicy: string; +} + +export interface ICloudApiDatabaseModule { + id: number; + name: string; + version: string; + description?: string; + parameters?: any[]; +} + +interface ICloudApiDatabaseSecurity { + password?: string; + sslClientAuthentication: boolean; + sourceIps: string[]; +} + +export interface ICloudApiDatabase { + databaseId: number; + name: string; + protocol: CloudDatabaseProtocol; + provider: string; + region: string; + redisVersionCompliance: string; + status: CloudDatabaseStatus; + 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: ICloudApiDatabaseClustering; + security: ICloudApiDatabaseSecurity; + modules: ICloudApiDatabaseModule[]; + alerts: ICloudApiAlert[]; +} + +export interface ICloudApiSubscriptionDatabases { + accountId: number; + subscription: { + subscriptionId: number; + numberOfDatabases: number; + databases: ICloudApiDatabase[]; + }[]; +} + +// Account interfaces +export interface ICloudApiAccountOwner { + name: string; + email: string; +} + +interface ICloudApiAccountKey { + name: string; + accountId: number; + accountName: string; + allowedSourceIps: string[]; + createdTimestamp: string; + owner: ICloudApiAccountOwner; + httpSourceIp: string; +} + +export interface ICloudApiAccount { + id: number; + name: string; + createdTimestamp: string; + updatedTimestamp: string; + key: ICloudApiAccountKey; +} + +// Subscription interfaces +interface ICloudApiSubscriptionPricing { + type: string; + typeDetails?: string; + quantity: number; + quantityMeasurement: string; + pricePerUnit?: number; + priceCurrency?: string; + pricePeriod?: string; +} + +interface ICloudApiSubscriptionRegion { + region: string; + networking: any[]; + preferredAvailabilityZones: string[]; + multipleAvailabilityZones: boolean; +} + +interface ICloudApiSubscriptionDetails { + provider: string; + cloudAccountId: number; + totalSizeInGb: number; + regions: ICloudApiSubscriptionRegion[]; +} + +export interface ICloudApiSubscription { + id: number; + name: string; + status: CloudSubscriptionStatus; + paymentMethodId: number; + memoryStorage: string; + storageEncryption: boolean; + numberOfDatabases: number; + subscriptionPricing: ICloudApiSubscriptionPricing[]; + cloudDetails: ICloudApiSubscriptionDetails[]; +} + +export interface ICloudApiSubscriptions { + accountId: number; + subscriptions: ICloudApiSubscription[]; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts new file mode 100644 index 0000000000..722fce6d56 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts @@ -0,0 +1,86 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; + +export enum CloudDatabaseProtocol { + Redis = 'redis', + Memcached = 'memcached', +} + +export enum CloudDatabasePersistencePolicy { + 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 enum CloudDatabaseMemoryStorage { + Ram = 'ram', + RamAndFlash = 'ram-and-flash', +} + +export enum CloudDatabaseStatus { + Pending = 'pending', + CreationFailed = 'creation-failed', + Active = 'active', + ActiveChangePending = 'active-change-pending', + ImportPending = 'import-pending', + DeletePending = 'delete-pending', + Recovery = 'recovery', +} + +export class CloudDatabase { + @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: CloudDatabaseStatus, + default: CloudDatabaseStatus.Active, + }) + status: CloudDatabaseStatus; + + @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; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts new file mode 100644 index 0000000000..cdf14c671c --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts @@ -0,0 +1,48 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum CloudSubscriptionStatus { + Active = 'active', + NotActivated = 'not_activated', + Deleting = 'deleting', + Pending = 'pending', + Error = 'error', +} + +export class CloudSubscription { + @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: CloudSubscriptionStatus, + default: CloudSubscriptionStatus.Active, + }) + status: CloudSubscriptionStatus; + + @ApiPropertyOptional({ + description: 'Subscription provider', + type: String, + }) + provider?: string; + + @ApiPropertyOptional({ + description: 'Subscription region', + type: String, + }) + region?: string; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts new file mode 100644 index 0000000000..d6ad7afb28 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts @@ -0,0 +1,4 @@ +export * from './cloud-account-info'; +export * from './cloud-api.interface'; +export * from './cloud-database'; +export * from './cloud-subscription'; diff --git a/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.spec.ts similarity index 82% rename from redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts rename to redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.spec.ts index a2a6f7d74d..cbf813848e 100644 --- a/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.spec.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.spec.ts @@ -1,5 +1,5 @@ import { AdditionalRedisModuleName } from 'src/constants'; -import { convertRECloudModuleName } from 'src/modules/redis-enterprise/utils/redis-cloud-converter'; +import { convertRECloudModuleName } from 'src/modules/cloud/autodiscovery/utils/redis-cloud-converter'; describe('convertRedisCloudModuleName', () => { it('should return exist module name', () => { diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts new file mode 100644 index 0000000000..14a96e5db5 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts @@ -0,0 +1,118 @@ +import { RE_CLOUD_MODULES_NAMES } from 'src/constants'; +import { get, find } from 'lodash'; +import { + CloudAccountInfo, + CloudDatabase, CloudDatabaseMemoryStorage, + CloudDatabasePersistencePolicy, CloudDatabaseProtocol, + CloudSubscription, +} from 'src/modules/cloud/autodiscovery/models'; +import { plainToClass } from 'class-transformer'; + +export function convertRECloudModuleName(name: string): string { + return RE_CLOUD_MODULES_NAMES[name] ?? name; +} + +export const parseCloudAccountResponse = (account: any): CloudAccountInfo => plainToClass(CloudAccountInfo, { + accountId: account.id, + accountName: account.name, + ownerName: get(account, ['key', 'owner', 'name']), + ownerEmail: get(account, ['key', 'owner', 'email']), +}); + +export const parseCloudSubscriptionsResponse = (subscriptions: any[]): CloudSubscription[] => { + const result: CloudSubscription[] = []; + if (subscriptions?.length) { + subscriptions.forEach((subscription): void => { + result.push(plainToClass(CloudSubscription, { + 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; +}; + +export const parseCloudDatabaseResponse = (database: any, subscriptionId: number): CloudDatabase => { + const { + databaseId, name, publicEndpoint, status, security, + } = database; + + return plainToClass(CloudDatabase, { + subscriptionId, + databaseId, + name, + publicEndpoint, + status, + password: security?.password, + sslClientAuthentication: security.sslClientAuthentication, + modules: database.modules + .map((module) => convertRECloudModuleName(module.name)), + options: { + enabledDataPersistence: + database.dataPersistence !== CloudDatabasePersistencePolicy.None, + persistencePolicy: database.dataPersistence, + enabledRedisFlash: + database.memoryStorage === CloudDatabaseMemoryStorage.RamAndFlash, + enabledReplication: database.replication, + enabledBackup: !!database.periodicBackupPath, + enabledClustering: database.clustering.numberOfShards > 1, + isReplicaDestination: !!database.replicaOf, + }, + }); +}; + +export const findReplicasForDatabase = (databases: any[], sourceDatabaseId: number): any[] => { + const sourceDatabase = find(databases, { + databaseId: sourceDatabaseId, + }); + if (!sourceDatabase) { + return []; + } + return databases.filter((replica): 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) + )); + }); +}; + +export const parseCloudDatabasesInSubscriptionResponse = (response: any): CloudDatabase[] => { + const subscription = response.subscription[0]; + const { subscriptionId, databases } = subscription; + let result: CloudDatabase[] = []; + databases.forEach((database): void => { + // We do not send the databases which have 'memcached' as their protocol. + if (database.protocol === CloudDatabaseProtocol.Redis) { + result.push(parseCloudDatabaseResponse(database, subscriptionId)); + } + }); + result = result.map((database) => ({ + ...database, + options: { + ...database.options, + isReplicaSource: !!findReplicasForDatabase( + databases, + database.databaseId, + ).length, + }, + })); + return result; +}; diff --git a/redisinsight/api/src/modules/cloud/cloud.module.ts b/redisinsight/api/src/modules/cloud/cloud.module.ts new file mode 100644 index 0000000000..6fabefd287 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/cloud.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CloudAutodiscoveryModule } from 'src/modules/cloud/autodiscovery/cloud.autodiscovery.module'; + +@Module({}) +export class CloudModule { + static register() { + return { + module: CloudModule, + imports: [CloudAutodiscoveryModule], + }; + } +} diff --git a/redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts b/redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts deleted file mode 100644 index 3d0041d18d..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/dto/cloud.dto.ts +++ /dev/null @@ -1,203 +0,0 @@ -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/redis-enterprise-cloud.dto.ts b/redisinsight/api/src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto.ts deleted file mode 100644 index a5743fabd8..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/dto/redis-enterprise-cloud.dto.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - ArrayNotEmpty, - IsArray, - IsDefined, - IsInt, - IsNotEmpty, - ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { - CloudAuthDto, - RedisCloudDatabase, -} from 'src/modules/redis-enterprise/dto/cloud.dto'; -import { ActionStatus } from 'src/common/models'; - -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: ActionStatus.Success, - enum: ActionStatus, - }) - status: ActionStatus; - - @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/redis-enterprise/models/redis-cloud-account.ts b/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts deleted file mode 100644 index 1d6feb14ea..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-account.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 440d23d684..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-database.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index ed43e5230f..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/models/redis-cloud-subscriptions.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 index f8dc510ca9..9931013efe 100644 --- a/redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts +++ b/redisinsight/api/src/modules/redis-enterprise/models/redis-enterprise-database.ts @@ -1,3 +1,12 @@ +export enum RedisEnterprisePersistencePolicy { + 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 IRedisEnterpriseDatabase { gradual_src_mode: string; group_uid: number; diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.spec.ts b/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.spec.ts deleted file mode 100644 index 7322e21313..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/redis-cloud.service.spec.ts +++ /dev/null @@ -1,494 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import axios, { AxiosError } from 'axios'; -import { - ForbiddenException, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; -import { mockDatabaseService, mockRedisEnterpriseAnalytics } 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 { RedisCloudService } from 'src/modules/redis-enterprise/redis-cloud.service'; -import { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; -import { DatabaseService } from 'src/modules/database/database.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('RedisCloudService', () => { - let service: RedisCloudService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - RedisCloudService, - { - provide: DatabaseService, - useFactory: mockDatabaseService, - }, - { - provide: RedisEnterpriseAnalytics, - useFactory: mockRedisEnterpriseAnalytics, - }, - ], - }).compile(); - - service = module.get(RedisCloudService); - }); - - 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/redis-enterprise/redis-enterprise.analytics.spec.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.spec.ts index 268c091bdc..b9009dbaac 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.spec.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.spec.ts @@ -2,12 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { TelemetryEvents } from 'src/constants'; import { - mockRedisCloudDatabaseDto, - mockRedisCloudSubscriptionDto, mockRedisEnterpriseDatabaseDto, } 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 { InternalServerErrorException } from '@nestjs/common'; import { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; @@ -128,185 +125,4 @@ describe('RedisEnterpriseAnalytics', () => { ); }); }); - - 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, - ); - }); - }); }); diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.ts index 4a6197f2e4..e6c77d221f 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.analytics.ts @@ -3,8 +3,6 @@ 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 { TelemetryBaseService } from 'src/modules/analytics/telemetry.base.service'; @Injectable() @@ -32,44 +30,4 @@ export class RedisEnterpriseAnalytics extends TelemetryBaseService { 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); - } } diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts index e3923ef68c..5659a2abfe 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.module.ts @@ -1,15 +1,12 @@ import { Module } from '@nestjs/common'; import { RedisEnterpriseService } from 'src/modules/redis-enterprise/redis-enterprise.service'; -import { RedisCloudService } from 'src/modules/redis-enterprise/redis-cloud.service'; import { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; import { ClusterController } from './controllers/cluster.controller'; -import { CloudController } from './controllers/cloud.controller'; @Module({ - controllers: [ClusterController, CloudController], + controllers: [ClusterController], providers: [ RedisEnterpriseService, - RedisCloudService, RedisEnterpriseAnalytics, ], }) diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.spec.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.spec.ts index 5794beb85b..c521df34a7 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.spec.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.spec.ts @@ -9,8 +9,8 @@ import { RedisEnterpriseDatabaseAofPolicy, RedisEnterpriseDatabasePersistence, RedisEnterpriseDatabaseStatus, + RedisEnterprisePersistencePolicy, } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; -import { RedisPersistencePolicy } from 'src/modules/redis-enterprise/models/redis-cloud-database'; import { RedisEnterpriseService } from 'src/modules/redis-enterprise/redis-enterprise.service'; import { ClusterConnectionDetailsDto } from 'src/modules/redis-enterprise/dto/cluster.dto'; import { RedisEnterpriseAnalytics } from 'src/modules/redis-enterprise/redis-enterprise.analytics'; @@ -221,7 +221,7 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Aof, aof_policy: RedisEnterpriseDatabaseAofPolicy.AofEveryOneSecond, }); - expect(result).toEqual(RedisPersistencePolicy.AofEveryOneSecond); + expect(result).toEqual(RedisEnterprisePersistencePolicy.AofEveryOneSecond); }); it('should return AofEveryWrite', async () => { const result = service.getDatabasePersistencePolicy({ @@ -229,7 +229,7 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Aof, aof_policy: RedisEnterpriseDatabaseAofPolicy.AofEveryWrite, }); - expect(result).toEqual(RedisPersistencePolicy.AofEveryWrite); + expect(result).toEqual(RedisEnterprisePersistencePolicy.AofEveryWrite); }); it('should return SnapshotEveryOneHour', async () => { const result = service.getDatabasePersistencePolicy({ @@ -237,7 +237,7 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Snapshot, snapshot_policy: [{ secs: 3600 }], }); - expect(result).toEqual(RedisPersistencePolicy.SnapshotEveryOneHour); + expect(result).toEqual(RedisEnterprisePersistencePolicy.SnapshotEveryOneHour); }); it('should return SnapshotEverySixHours', async () => { const result = service.getDatabasePersistencePolicy({ @@ -245,7 +245,7 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Snapshot, snapshot_policy: [{ secs: 21600 }], }); - expect(result).toEqual(RedisPersistencePolicy.SnapshotEverySixHours); + expect(result).toEqual(RedisEnterprisePersistencePolicy.SnapshotEverySixHours); }); it('should return SnapshotEveryTwelveHours', async () => { const result = service.getDatabasePersistencePolicy({ @@ -253,14 +253,14 @@ describe('RedisEnterpriseService', () => { data_persistence: RedisEnterpriseDatabasePersistence.Snapshot, snapshot_policy: [{ secs: 43200 }], }); - expect(result).toEqual(RedisPersistencePolicy.SnapshotEveryTwelveHours); + expect(result).toEqual(RedisEnterprisePersistencePolicy.SnapshotEveryTwelveHours); }); it('should return None', async () => { const result = service.getDatabasePersistencePolicy({ ...mockREClusterDatabase, data_persistence: null, }); - expect(result).toEqual(RedisPersistencePolicy.None); + expect(result).toEqual(RedisEnterprisePersistencePolicy.None); }); }); diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.ts index c03c98b57d..53a918f660 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.service.ts @@ -14,8 +14,8 @@ import { IRedisEnterpriseReplicaSource, RedisEnterpriseDatabaseAofPolicy, RedisEnterpriseDatabasePersistence, + RedisEnterprisePersistencePolicy, } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; -import { RedisPersistencePolicy } from 'src/modules/redis-enterprise/models/redis-cloud-database'; import { ClusterConnectionDetailsDto, RedisEnterpriseDatabase, @@ -140,27 +140,27 @@ export class RedisEnterpriseService { private getDatabasePersistencePolicy( database: IRedisEnterpriseDatabase, - ): RedisPersistencePolicy { + ): RedisEnterprisePersistencePolicy { // 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; + ? RedisEnterprisePersistencePolicy.AofEveryOneSecond + : RedisEnterprisePersistencePolicy.AofEveryWrite; } if (data_persistence === RedisEnterpriseDatabasePersistence.Snapshot) { const { secs } = snapshot_policy.pop(); if (secs === 3600) { - return RedisPersistencePolicy.SnapshotEveryOneHour; + return RedisEnterprisePersistencePolicy.SnapshotEveryOneHour; } if (secs === 21600) { - return RedisPersistencePolicy.SnapshotEverySixHours; + return RedisEnterprisePersistencePolicy.SnapshotEverySixHours; } if (secs === 43200) { - return RedisPersistencePolicy.SnapshotEveryTwelveHours; + return RedisEnterprisePersistencePolicy.SnapshotEveryTwelveHours; } } - return RedisPersistencePolicy.None; + return RedisEnterprisePersistencePolicy.None; } private findReplicasForDatabase( 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 deleted file mode 100644 index 1e8dbac14a..0000000000 --- a/redisinsight/api/src/modules/redis-enterprise/utils/redis-cloud-converter.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_account.test.ts similarity index 58% rename from redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts rename to redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_account.test.ts index 19e40963a4..48c481571b 100644 --- a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_account.test.ts +++ b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_account.test.ts @@ -1,16 +1,17 @@ import { describe, - it, deps, - validateApiCall, requirements, generateInvalidDataTestCases, validateInvalidDataTestCase, Joi, + nock, getMainCheckFn, + serverConfig, } from '../deps'; +import { mockCloudAccountInfo, mockCloudApiAccount } from 'src/__mocks__/cloud-autodiscovery'; const { request, server, constants } = deps; -const endpoint = () => request(server).post(`/redis-enterprise/cloud/get-account`); +const endpoint = () => request(server).post(`/cloud/autodiscovery/get-account`); const dataSchema = Joi.object({ apiKey: Joi.string().required(), @@ -29,17 +30,12 @@ const responseSchema = Joi.object().keys({ ownerEmail: Joi.string().required(), }).required(); -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - await validateApiCall({ - endpoint, - ...testCase, - }); - }); -}; +const mainCheckFn = getMainCheckFn(endpoint); + +const nockScope = nock(serverConfig.get('redis_cloud').url); -describe('POST /redis-enterprise/cloud/get-account', () => { - requirements('rte.cloud'); +describe('POST /cloud/autodiscovery/get-account', () => { + requirements('rte.serverType=local'); describe('Validation', () => { generateInvalidDataTestCases(dataSchema, validInputData).map( @@ -50,17 +46,31 @@ describe('POST /redis-enterprise/cloud/get-account', () => { describe('Common', () => { [ { + before: () => { + nockScope.get('/') + .reply(200, { account: mockCloudApiAccount }); + }, name: 'Should get account info', data: { apiKey: constants.TEST_CLOUD_API_KEY, apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, }, responseSchema, + responseBody: mockCloudAccountInfo, }, { - name: 'Should throw Forbidden error when api key is incorrect', + before: () => { + nockScope.get('/') + .reply(403, { + response: { + status: 403, + data: { message: 'Unauthorized for this action' }, + } + }); + }, + name: 'Should throw Forbidden error when api returned unauthorized error', data: { - apiKey: 'wrong-api-key', + apiKey: constants.TEST_CLOUD_API_KEY, apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, }, statusCode: 403, @@ -68,20 +78,27 @@ describe('POST /redis-enterprise/cloud/get-account', () => { statusCode: 403, error: 'Forbidden', }, - }, { - name: 'Should throw Forbidden error when api secret key is incorrect', + before: () => { + nockScope.get('/') + .reply(401, { + response: { + status: 401, + data: '', + } + }); + }, + name: 'Should throw Forbidden error when api key is incorrect', data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: 'wrong-api-secret-key', + apiKey: 'wrong-api-key', + apiSecretKey: constants.TEST_CLOUD_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-cloud-autodiscovery-get_databases.test.ts similarity index 63% rename from redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_databases.test.ts rename to redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts index e284423543..031b5f339d 100644 --- a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_databases.test.ts +++ b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts @@ -1,19 +1,17 @@ import { describe, - it, - before, deps, - validateApiCall, + expect, requirements, generateInvalidDataTestCases, validateInvalidDataTestCase, - expect, - _, - Joi, + Joi, getMainCheckFn, serverConfig } from '../deps'; +import { nock } from '../../helpers/test'; +import { mockCloudApiDatabases, mockCloudDatabaseFromList } from 'src/__mocks__/cloud-autodiscovery'; const { request, server, constants } = deps; -const endpoint = () => request(server).post(`/redis-enterprise/cloud/get-databases`); +const endpoint = () => request(server).post(`/cloud/autodiscovery/get-databases`); const dataSchema = Joi.object({ apiKey: Joi.string().required(), @@ -38,17 +36,12 @@ const responseSchema = Joi.array().items(Joi.object().keys({ options: Joi.object().required(), })).required(); -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - await validateApiCall({ - endpoint, - ...testCase, - }); - }); -}; +const mainCheckFn = getMainCheckFn(endpoint); -describe('POST /redis-enterprise/cloud/get-databases', () => { - requirements('rte.cloud'); +const nockScope = nock(serverConfig.get('redis_cloud').url); + +describe('POST /cloud/subscriptions/get-databases', () => { + requirements('rte.serverType=local'); describe('Validation', () => { generateInvalidDataTestCases(dataSchema, validInputData).map( @@ -59,6 +52,10 @@ describe('POST /redis-enterprise/cloud/get-databases', () => { describe('Common', async () => { [ { + before: () => { + nockScope.get(`/subscriptions/${constants.TEST_CLOUD_SUBSCRIPTION_ID}/databases`) + .reply(200, mockCloudApiDatabases); + }, name: 'Should get databases list inside subscription', data: { apiKey: constants.TEST_CLOUD_API_KEY, @@ -67,11 +64,20 @@ describe('POST /redis-enterprise/cloud/get-databases', () => { }, 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}`); + + expect(body).to.deep.eq([mockCloudDatabaseFromList]); }, }, { + before: () => { + nockScope.get(`/subscriptions/${constants.TEST_CLOUD_SUBSCRIPTION_ID}/databases`) + .reply(403, { + response: { + status: 403, + data: { message: 'Unauthorized for this action' }, + } + }); + }, name: 'Should throw Forbidden error when api key is incorrect', data: { apiKey: 'wrong-api-key', @@ -85,6 +91,15 @@ describe('POST /redis-enterprise/cloud/get-databases', () => { }, }, { + before: () => { + nockScope.get(`/subscriptions/${constants.TEST_CLOUD_SUBSCRIPTION_ID}/databases`) + .reply(401, { + response: { + status: 401, + data: '', + } + }); + }, name: 'Should throw Forbidden error when api secret key is incorrect', data: { apiKey: constants.TEST_CLOUD_API_KEY, @@ -98,6 +113,15 @@ describe('POST /redis-enterprise/cloud/get-databases', () => { }, }, { + before: () => { + nockScope.get(`/subscriptions/${constants.TEST_CLOUD_SUBSCRIPTION_ID}/databases`) + .reply(404, { + response: { + status: 404, + data: 'Subscription is not found', + } + }); + }, name: 'Should throw Not Found error when subscription id is not found', data: { apiKey: constants.TEST_CLOUD_API_KEY, diff --git a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_subscriptions.test.ts similarity index 57% rename from redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts rename to redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_subscriptions.test.ts index 64b6953382..a9519786b3 100644 --- a/redisinsight/api/test/api/cloud/POST-redis_enterprise-cloud-get_subscriptions.test.ts +++ b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_subscriptions.test.ts @@ -1,18 +1,16 @@ import { describe, - it, deps, - validateApiCall, requirements, generateInvalidDataTestCases, validateInvalidDataTestCase, - expect, - _, - Joi, + Joi, getMainCheckFn, serverConfig } from '../deps'; +import { nock } from '../../helpers/test'; +import { mockCloudApiSubscription, mockCloudSubscription } from 'src/__mocks__/cloud-autodiscovery'; const { request, server, constants } = deps; -const endpoint = () => request(server).post(`/redis-enterprise/cloud/get-subscriptions`); +const endpoint = () => request(server).post(`/cloud/autodiscovery/get-subscriptions`); const dataSchema = Joi.object({ apiKey: Joi.string().required(), @@ -33,17 +31,12 @@ const responseSchema = Joi.array().items(Joi.object().keys({ region: Joi.string(), })).required(); -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - await validateApiCall({ - endpoint, - ...testCase, - }); - }); -}; +const mainCheckFn = getMainCheckFn(endpoint); + +const nockScope = nock(serverConfig.get('redis_cloud').url); -describe('POST /redis-enterprise/cloud/get-subscriptions', () => { - requirements('rte.cloud'); +describe('POST /cloud/autodiscovery/get-subscriptions', () => { + requirements('rte.serverType=local'); describe('Validation', () => { generateInvalidDataTestCases(dataSchema, validInputData).map( @@ -54,20 +47,31 @@ describe('POST /redis-enterprise/cloud/get-subscriptions', () => { describe('Common', () => { [ { + before: () => { + nockScope.get('/subscriptions') + .reply(200, { subscriptions: [mockCloudApiSubscription] }); + }, 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); - }, + responseBody: [mockCloudSubscription], }, { - name: 'Should throw Forbidden error when api key is incorrect', + before: () => { + nockScope.get('/subscriptions') + .reply(403, { + response: { + status: 403, + data: { message: 'Unauthorized for this action' }, + } + }); + }, + name: 'Should throw Forbidden error when api returned unauthorized error', data: { - apiKey: 'wrong-api-key', + apiKey: constants.TEST_CLOUD_API_KEY, apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, }, statusCode: 403, @@ -75,20 +79,27 @@ describe('POST /redis-enterprise/cloud/get-subscriptions', () => { statusCode: 403, error: 'Forbidden', }, - }, { - name: 'Should throw Forbidden error when api secret key is incorrect', + before: () => { + nockScope.get('/subscriptions') + .reply(401, { + response: { + status: 401, + data: '', + } + }); + }, + name: 'Should throw Forbidden error when api key is incorrect', data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: 'wrong-api-secret-key', + apiKey: 'wrong-api-key', + apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, }, statusCode: 403, responseBody: { statusCode: 403, error: 'Forbidden', }, - }, ].map(mainCheckFn); }); diff --git a/redisinsight/api/test/api/deps.ts b/redisinsight/api/test/api/deps.ts index 032e6e7892..88293c6db4 100644 --- a/redisinsight/api/test/api/deps.ts +++ b/redisinsight/api/test/api/deps.ts @@ -12,6 +12,12 @@ import { testEnv } from '../helpers/test'; import * as redis from '../helpers/redis'; import { initCloudDatabase } from '../helpers/cloud'; +// Just dummy jest module implementation to be able to use common mocked models in UTests and ITests +global['jest'] = { + // @ts-ignore + fn: () => {} +}; + /** * Initialize dependencies */ diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index dc2033c459..d28700a8c2 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -103,10 +103,10 @@ export const constants = { // cloud TEST_CLOUD_RTE: process.env.TEST_CLOUD_RTE, TEST_CLOUD_API: process.env.REDIS_CLOUD_URL || process.env.TEST_CLOUD_API || 'https://api.qa.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_API_KEY: process.env.TEST_CLOUD_API_KEY || 'TEST_CLOUD_API_KEY', + TEST_CLOUD_API_SECRET_KEY: process.env.TEST_CLOUD_API_SECRET_KEY || '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_SUBSCRIPTION_ID: process.env.TEST_CLOUD_SUBSCRIPTION_ID || 1, TEST_CLOUD_DATABASE_NAME: process.env.TEST_CLOUD_DATABASE_NAME || 'ITests-db', STANDALONE: 'STANDALONE', diff --git a/redisinsight/api/test/helpers/test.ts b/redisinsight/api/test/helpers/test.ts index 5d8de66edc..386e22e153 100644 --- a/redisinsight/api/test/helpers/test.ts +++ b/redisinsight/api/test/helpers/test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as fsExtra from 'fs-extra'; import * as chai from 'chai'; +import * as nock from 'nock'; import * as Joi from 'joi'; import * as AdmZip from 'adm-zip'; import * as diff from 'object-diff'; @@ -13,7 +14,7 @@ import { cloneDeep, isMatch, isObject, set, isArray } from 'lodash'; import { generateInvalidDataArray } from './test/dataGenerator'; import serverConfig from 'src/utils/config'; -export { _, path, fs, fsExtra, AdmZip, serverConfig, axios } +export { _, path, fs, fsExtra, AdmZip, serverConfig, axios, nock } export const expect = chai.expect; export const testEnv: Record = {}; export { Joi, describe, it, before, after, beforeEach }; From f345d60b4ccb5bbfbbce67222b72063cf8b016b0 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 15 Jun 2023 13:30:14 +0300 Subject: [PATCH 2/9] #RI-4530 moved clooud api secrets from body to headers --- .../api/src/__mocks__/cloud-autodiscovery.ts | 2 +- .../cloud-autodiscovery.service.spec.ts | 27 +++----- .../cloud-autodiscovery.service.ts | 62 +++++++++++-------- .../cloud.autodiscovery.controller.ts | 41 +++++++----- .../decorators/cloud-auth.decorator.ts | 25 ++++++++ .../dto/add-cloud-databases.dto.ts | 3 +- .../cloud/autodiscovery/dto/cloud-auth.dto.ts | 2 +- .../dto/get-cloud-databases.dto.ts | 3 +- .../get-cloud-subscription-database.dto.ts | 3 +- .../get-cloud-subscription-databases.dto.ts | 3 +- ...> GET-cloud-autodiscovery-account.test.ts} | 52 ++++++---------- ...GET-cloud-autodiscovery-databases.test.ts} | 42 +++++++------ ...cloud-autodiscovery-subscriptions.test.ts} | 52 ++++++---------- redisinsight/api/test/helpers/test.ts | 9 +++ 14 files changed, 172 insertions(+), 154 deletions(-) create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/decorators/cloud-auth.decorator.ts rename redisinsight/api/test/api/cloud/{POST-cloud-autodiscovery-get_account.test.ts => GET-cloud-autodiscovery-account.test.ts} (59%) rename redisinsight/api/test/api/cloud/{POST-cloud-autodiscovery-get_databases.test.ts => GET-cloud-autodiscovery-databases.test.ts} (78%) rename redisinsight/api/test/api/cloud/{POST-cloud-autodiscovery-get_subscriptions.test.ts => GET-cloud-autodiscovery-subscriptions.test.ts} (61%) diff --git a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts index 56077cca4c..082ed3621e 100644 --- a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts +++ b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts @@ -173,7 +173,7 @@ export const mockCloudApiDatabases = { export const mockCloudAuthDto: CloudAuthDto = { apiKey: 'api_key', - apiSecretKey: 'api_secret_key', + apiSecret: 'api_secret_key', }; export const mockCloudAutodiscoveryAnalytics = jest.fn(() => ({ diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts index 77005c1e24..a67a18cda6 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts @@ -101,8 +101,7 @@ describe('CloudAutodiscoveryService', () => { data: mockCloudApiDatabase, }); - expect(await service.getSubscriptionDatabase({ - ...mockCloudAuthDto, + expect(await service.getSubscriptionDatabase(mockCloudAuthDto, { subscriptionId: mockCloudSubscription.id, databaseId: mockCloudDatabase.databaseId, })).toEqual(mockCloudDatabase); @@ -111,8 +110,7 @@ describe('CloudAutodiscoveryService', () => { mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); await expect( - service.getSubscriptionDatabase({ - ...mockCloudAuthDto, + service.getSubscriptionDatabase(mockCloudAuthDto, { subscriptionId: mockCloudSubscription.id, databaseId: mockCloudDatabase.databaseId, }), @@ -128,8 +126,7 @@ describe('CloudAutodiscoveryService', () => { mockedAxios.get.mockRejectedValue(apiResponse); await expect( - service.getSubscriptionDatabase({ - ...mockCloudAuthDto, + service.getSubscriptionDatabase(mockCloudAuthDto, { subscriptionId: mockCloudSubscription.id, databaseId: mockCloudDatabase.databaseId, }), @@ -144,16 +141,14 @@ describe('CloudAutodiscoveryService', () => { data: mockCloudApiDatabases, }); - expect(await service.getSubscriptionDatabases({ - ...mockCloudAuthDto, + expect(await service.getSubscriptionDatabases(mockCloudAuthDto, { subscriptionId: mockCloudSubscription.id, })).toEqual([mockCloudDatabaseFromList]); }); it('the user could not be authenticated', async () => { mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); - await expect(service.getSubscriptionDatabases({ - ...mockCloudAuthDto, + await expect(service.getSubscriptionDatabases(mockCloudAuthDto, { subscriptionId: mockCloudSubscription.id, })).rejects.toThrow(ForbiddenException); }); @@ -165,8 +160,7 @@ describe('CloudAutodiscoveryService', () => { }, }); - await expect(service.getSubscriptionDatabases({ - ...mockCloudAuthDto, + await expect(service.getSubscriptionDatabases(mockCloudAuthDto, { subscriptionId: mockCloudSubscription.id, })).rejects.toThrow(NotFoundException); }); @@ -177,16 +171,14 @@ describe('CloudAutodiscoveryService', () => { service.getSubscriptionDatabases = jest.fn().mockResolvedValue([]); }); it('should call getDatabasesInSubscription', async () => { - await service.getDatabases({ - ...mockCloudAuthDto, + await service.getDatabases(mockCloudAuthDto, { subscriptionIds: [86070, 86071], }); expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); }); it('should not call getDatabasesInSubscription for duplicated ids', async () => { - await service.getDatabases({ - ...mockCloudAuthDto, + await service.getDatabases(mockCloudAuthDto, { subscriptionIds: [86070, 86070, 86071], }); @@ -198,8 +190,7 @@ describe('CloudAutodiscoveryService', () => { .mockRejectedValue(new NotFoundException()); await expect( - service.getDatabases({ - ...mockCloudAuthDto, + service.getDatabases(mockCloudAuthDto, { subscriptionIds: [86070, 86071], }), ).rejects.toThrow(NotFoundException); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts index 4225e8d01e..4ef1f39270 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts @@ -51,13 +51,13 @@ export class CloudAutodiscoveryService { /** * Generates auth headers to attach to the request * @param apiKey - * @param apiSecretKey + * @param apiSecret * @private */ - static getAuthHeaders(apiKey: string, apiSecretKey: string) { + static getAuthHeaders(apiKey: string, apiSecret: string) { return { 'x-api-key': apiKey, - 'x-api-secret-key': apiSecretKey, + 'x-api-secret-key': apiSecret, }; } @@ -95,16 +95,16 @@ export class CloudAutodiscoveryService { /** * Get cloud account short info - * @param dto + * @param authDto */ - async getAccount(dto: CloudAuthDto): Promise { + async getAccount(authDto: CloudAuthDto): Promise { this.logger.log('Getting cloud account.'); - const { apiKey, apiSecretKey } = dto; + const { apiKey, apiSecret } = authDto; try { const { data: { account }, }: AxiosResponse = await this.api.get(`${this.config.url}/`, { - headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecretKey), + headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecret), }); this.logger.log('Succeed to get RE cloud account.'); @@ -117,18 +117,18 @@ export class CloudAutodiscoveryService { /** * Get list of account subscriptions - * @param dto + * @param authDto */ - async getSubscriptions(dto: CloudAuthDto): Promise { + async getSubscriptions(authDto: CloudAuthDto): Promise { this.logger.log('Getting RE cloud subscriptions.'); - const { apiKey, apiSecretKey } = dto; + const { apiKey, apiSecret } = authDto; try { const { data: { subscriptions }, }: AxiosResponse = await this.api.get( `${this.config.url}/subscriptions`, { - headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecretKey), + headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecret), }, ); this.logger.log('Succeed to get RE cloud subscriptions.'); @@ -144,12 +144,15 @@ export class CloudAutodiscoveryService { /** * Get single database details + * @param authDto * @param dto */ - async getSubscriptionDatabase(dto: GetCloudSubscriptionDatabaseDto): Promise { - const { - apiKey, apiSecretKey, subscriptionId, databaseId, - } = dto; + async getSubscriptionDatabase( + authDto: CloudAuthDto, + dto: GetCloudSubscriptionDatabaseDto, + ): Promise { + const { apiKey, apiSecret } = authDto; + const { subscriptionId, databaseId } = dto; this.logger.log( `Getting database in RE cloud subscription. subscription id: ${subscriptionId}, database id: ${databaseId}`, ); @@ -157,7 +160,7 @@ export class CloudAutodiscoveryService { const { data }: AxiosResponse = await this.api.get( `${this.config.url}/subscriptions/${subscriptionId}/databases/${databaseId}`, { - headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecretKey), + headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecret), }, ); this.logger.log('Succeed to get databases in RE cloud subscription.'); @@ -179,10 +182,15 @@ export class CloudAutodiscoveryService { /** * Get list of databases for subscription + * @param authDto * @param dto */ - async getSubscriptionDatabases(dto: GetCloudSubscriptionDatabasesDto): Promise { - const { apiKey, apiSecretKey, subscriptionId } = dto; + async getSubscriptionDatabases( + authDto: CloudAuthDto, + dto: GetCloudSubscriptionDatabasesDto, + ): Promise { + const { apiKey, apiSecret } = authDto; + const { subscriptionId } = dto; this.logger.log( `Getting databases in RE cloud subscription. subscription id: ${subscriptionId}`, ); @@ -190,7 +198,7 @@ export class CloudAutodiscoveryService { const { data }: AxiosResponse = await this.api.get( `${this.config.url}/subscriptions/${subscriptionId}/databases`, { - headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecretKey), + headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecret), }, ); this.logger.log('Succeed to get databases in RE cloud subscription.'); @@ -216,19 +224,20 @@ export class CloudAutodiscoveryService { /** * Get get all databases from specified multiple subscriptions + * @param authDto * @param dto */ - async getDatabases(dto: GetCloudDatabasesDto): Promise { - const { apiKey, apiSecretKey } = dto; + async getDatabases( + authDto: CloudAuthDto, + dto: GetCloudDatabasesDto, + ): Promise { 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.getSubscriptionDatabases({ - apiKey, - apiSecretKey, + const databases = await this.getSubscriptionDatabases(authDto, { subscriptionId, }); result = [...result, ...databases]; @@ -243,7 +252,7 @@ export class CloudAutodiscoveryService { } async addRedisCloudDatabases( - auth: CloudAuthDto, + authDto: CloudAuthDto, addDatabasesDto: AddCloudDatabaseDto[], ): Promise { this.logger.log('Adding Redis Cloud databases.'); @@ -254,8 +263,7 @@ export class CloudAutodiscoveryService { async ( dto: AddCloudDatabaseResponse, ): Promise => { - const database = await this.getSubscriptionDatabase({ - ...auth, + const database = await this.getSubscriptionDatabase(authDto, { ...dto, }); try { diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts index fa3b386bc2..d11f885914 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts @@ -1,14 +1,14 @@ import { Body, ClassSerializerInterceptor, - Controller, + Controller, Get, Post, Res, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; import { TimeoutInterceptor } from 'src/common/interceptors/timeout.interceptor'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiHeaders, ApiTags } from '@nestjs/swagger'; import { CloudAccountInfo, CloudDatabase, CloudSubscription } from 'src/modules/cloud/autodiscovery/models'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; import { Response } from 'express'; @@ -21,14 +21,20 @@ import { CloudAuthDto, GetCloudDatabasesDto, } from 'src/modules/cloud/autodiscovery/dto'; +import { CloudAuthHeaders } from 'src/modules/cloud/autodiscovery/decorators/cloud-auth.decorator'; @ApiTags('Cloud Autodiscovery') +@ApiHeaders([{ + name: 'x-cloud-api-key', +}, { + name: 'x-cloud-api-secret', +}]) @UsePipes(new ValidationPipe({ transform: true })) @Controller('cloud/autodiscovery') export class CloudAutodiscoveryController { constructor(private service: CloudAutodiscoveryService) {} - @Post('get-account') + @Get('account') @UseInterceptors(new TimeoutInterceptor()) @ApiEndpoint({ description: 'Get current account', @@ -42,11 +48,11 @@ export class CloudAutodiscoveryController { }, ], }) - async getAccount(@Body() dto: CloudAuthDto): Promise { - return await this.service.getAccount(dto); + async getAccount(@CloudAuthHeaders() authDto: CloudAuthDto): Promise { + return await this.service.getAccount(authDto); } - @Post('get-subscriptions') + @Get('subscriptions') @UseInterceptors(new TimeoutInterceptor()) @ApiEndpoint({ description: 'Get information about current account’s subscriptions.', @@ -61,11 +67,11 @@ export class CloudAutodiscoveryController { }, ], }) - async getSubscriptions(@Body() dto: CloudAuthDto): Promise { - return await this.service.getSubscriptions(dto); + async getSubscriptions(@CloudAuthHeaders() authDto: CloudAuthDto): Promise { + return await this.service.getSubscriptions(authDto); } - @Post('get-databases') + @Get('databases') @UseInterceptors(ClassSerializerInterceptor) @ApiEndpoint({ description: 'Get databases belonging to subscriptions', @@ -80,8 +86,11 @@ export class CloudAutodiscoveryController { }, ], }) - async getDatabases(@Body() dto: GetCloudDatabasesDto): Promise { - return await this.service.getDatabases(dto); + async getDatabases( + @CloudAuthHeaders() authDto: CloudAuthDto, + @Body() dto: GetCloudDatabasesDto, + ): Promise { + return await this.service.getDatabases(authDto, dto); } @Post('databases') @@ -100,14 +109,12 @@ export class CloudAutodiscoveryController { }) @UsePipes(new ValidationPipe({ transform: true })) async addRedisCloudDatabases( - @Body() dto: AddCloudDatabasesDto, + @CloudAuthHeaders() authDto: CloudAuthDto, + @Body() dto: AddCloudDatabasesDto, @Res() res: Response, ): Promise { - const { databases, ...connectionDetails } = dto; - const result = await this.service.addRedisCloudDatabases( - connectionDetails, - databases, - ); + const { databases } = dto; + const result = await this.service.addRedisCloudDatabases(authDto, databases); const hasSuccessResult = result.some( (addResponse: AddCloudDatabaseResponse) => addResponse.status === ActionStatus.Success, ); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/decorators/cloud-auth.decorator.ts b/redisinsight/api/src/modules/cloud/autodiscovery/decorators/cloud-auth.decorator.ts new file mode 100644 index 0000000000..6c621f6a42 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/decorators/cloud-auth.decorator.ts @@ -0,0 +1,25 @@ +import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { Validator } from 'class-validator'; +import { plainToClass } from 'class-transformer'; +import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto'; + +const validator = new Validator(); + +export const cloudAuthDtoFromRequestHeadersFactory = (data: unknown, ctx: ExecutionContext): CloudAuthDto => { + const request = ctx.switchToHttp().getRequest(); + + const dto = plainToClass(CloudAuthDto, { + apiKey: request.headers['x-cloud-api-key'], + apiSecret: request.headers['x-cloud-api-secret'], + }); + + const errors = validator.validateSync(dto); + + if (errors?.length) { + throw new UnauthorizedException('Required authentication credentials were not provided'); + } + + return dto; +}; + +export const CloudAuthHeaders = createParamDecorator(cloudAuthDtoFromRequestHeadersFactory); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts index e4622785ff..b528dc7943 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-databases.dto.ts @@ -3,10 +3,9 @@ import { ArrayNotEmpty, IsArray, IsDefined, ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; -import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto/cloud-auth.dto'; import { AddCloudDatabaseDto } from 'src/modules/cloud/autodiscovery/dto/add-cloud-database.dto'; -export class AddCloudDatabasesDto extends CloudAuthDto { +export class AddCloudDatabasesDto { @ApiProperty({ description: 'Cloud databases list.', type: AddCloudDatabaseDto, diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts index 0419336926..9f8958aece 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/cloud-auth.dto.ts @@ -18,5 +18,5 @@ export class CloudAuthDto { @IsDefined() @IsNotEmpty() @IsString({ always: true }) - apiSecretKey: string; + apiSecret: string; } diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts index a38231971d..f6ed5b985a 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts @@ -1,9 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsDefined, IsInt } from 'class-validator'; import { Transform, Type } from 'class-transformer'; -import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto/cloud-auth.dto'; -export class GetCloudDatabasesDto extends CloudAuthDto { +export class GetCloudDatabasesDto { @ApiProperty({ description: 'Subscription Ids', type: Number, diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts index 6981fd8314..410c964eb2 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts @@ -1,9 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsDefined, IsInt, IsNotEmpty } from 'class-validator'; import { Type } from 'class-transformer'; -import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto/cloud-auth.dto'; -export class GetCloudSubscriptionDatabaseDto extends CloudAuthDto { +export class GetCloudSubscriptionDatabaseDto { @ApiProperty({ description: 'Subscription Id', type: Number, diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts index 9d6e50c878..89a0803fc8 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts @@ -1,9 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsDefined, IsInt, IsNotEmpty } from 'class-validator'; import { Type } from 'class-transformer'; -import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto/cloud-auth.dto'; -export class GetCloudSubscriptionDatabasesDto extends CloudAuthDto { +export class GetCloudSubscriptionDatabasesDto { @ApiProperty({ description: 'Subscription Id', type: Number, diff --git a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_account.test.ts b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-account.test.ts similarity index 59% rename from redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_account.test.ts rename to redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-account.test.ts index 48c481571b..8e85751047 100644 --- a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_account.test.ts +++ b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-account.test.ts @@ -2,8 +2,6 @@ import { describe, deps, requirements, - generateInvalidDataTestCases, - validateInvalidDataTestCase, Joi, nock, getMainCheckFn, serverConfig, @@ -11,16 +9,11 @@ import { import { mockCloudAccountInfo, mockCloudApiAccount } from 'src/__mocks__/cloud-autodiscovery'; const { request, server, constants } = deps; -const endpoint = () => request(server).post(`/cloud/autodiscovery/get-account`); +const endpoint = () => request(server).get(`/cloud/autodiscovery/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 headers = { + 'x-cloud-api-key': constants.TEST_CLOUD_API_KEY, + 'x-cloud-api-secret': constants.TEST_CLOUD_API_SECRET_KEY, } const responseSchema = Joi.object().keys({ @@ -34,15 +27,9 @@ const mainCheckFn = getMainCheckFn(endpoint); const nockScope = nock(serverConfig.get('redis_cloud').url); -describe('POST /cloud/autodiscovery/get-account', () => { +describe('GET /cloud/autodiscovery/account', () => { requirements('rte.serverType=local'); - describe('Validation', () => { - generateInvalidDataTestCases(dataSchema, validInputData).map( - validateInvalidDataTestCase(endpoint, dataSchema), - ); - }); - describe('Common', () => { [ { @@ -51,10 +38,7 @@ describe('POST /cloud/autodiscovery/get-account', () => { .reply(200, { account: mockCloudApiAccount }); }, name: 'Should get account info', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, + headers, responseSchema, responseBody: mockCloudAccountInfo, }, @@ -68,11 +52,8 @@ describe('POST /cloud/autodiscovery/get-account', () => { } }); }, - name: 'Should throw Forbidden error when api returned unauthorized error', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, + name: 'Should throw Forbidden error when api returned 403 error', + headers, statusCode: 403, responseBody: { statusCode: 403, @@ -89,17 +70,24 @@ describe('POST /cloud/autodiscovery/get-account', () => { } }); }, - name: 'Should throw Forbidden error when api key is incorrect', - data: { - apiKey: 'wrong-api-key', - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, + name: 'Should throw Forbidden error when api returns 401 error', + headers, statusCode: 403, responseBody: { statusCode: 403, error: 'Forbidden', }, }, + { + name: 'Should throw Unauthorized error when api key or secret was not provided', + headers: {}, + statusCode: 401, + responseBody: { + statusCode: 401, + error: 'Unauthorized', + message: 'Required authentication credentials were not provided', + }, + }, ].map(mainCheckFn); }); }); diff --git a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-databases.test.ts similarity index 78% rename from redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts rename to redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-databases.test.ts index 031b5f339d..23a7842165 100644 --- a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts +++ b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-databases.test.ts @@ -11,20 +11,21 @@ import { nock } from '../../helpers/test'; import { mockCloudApiDatabases, mockCloudDatabaseFromList } from 'src/__mocks__/cloud-autodiscovery'; const { request, server, constants } = deps; -const endpoint = () => request(server).post(`/cloud/autodiscovery/get-databases`); +const endpoint = () => request(server).get(`/cloud/autodiscovery/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 headers = { + 'x-cloud-api-key': constants.TEST_CLOUD_API_KEY, + 'x-cloud-api-secret': constants.TEST_CLOUD_API_SECRET_KEY, +} + const responseSchema = Joi.array().items(Joi.object().keys({ subscriptionId: Joi.number().required(), databaseId: Joi.number().required(), @@ -40,11 +41,11 @@ const mainCheckFn = getMainCheckFn(endpoint); const nockScope = nock(serverConfig.get('redis_cloud').url); -describe('POST /cloud/subscriptions/get-databases', () => { +describe('GET /cloud/subscriptions/databases', () => { requirements('rte.serverType=local'); describe('Validation', () => { - generateInvalidDataTestCases(dataSchema, validInputData).map( + generateInvalidDataTestCases(dataSchema, validInputData, 'data', { headers }).map( validateInvalidDataTestCase(endpoint, dataSchema), ); }); @@ -58,10 +59,9 @@ describe('POST /cloud/subscriptions/get-databases', () => { }, 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] }, + headers, responseSchema, checkFn: ({ body }) => { @@ -78,10 +78,9 @@ describe('POST /cloud/subscriptions/get-databases', () => { } }); }, - name: 'Should throw Forbidden error when api key is incorrect', + name: 'Should throw Forbidden error when api returns 403', + headers, data: { - apiKey: 'wrong-api-key', - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] }, statusCode: 403, @@ -100,10 +99,9 @@ describe('POST /cloud/subscriptions/get-databases', () => { } }); }, - name: 'Should throw Forbidden error when api secret key is incorrect', + name: 'Should throw Forbidden error when api returns 401', + headers, data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: 'wrong-api-secret-key', subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] }, statusCode: 403, @@ -123,9 +121,8 @@ describe('POST /cloud/subscriptions/get-databases', () => { }); }, name: 'Should throw Not Found error when subscription id is not found', + headers, data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, subscriptionIds: [1] }, statusCode: 404, @@ -133,7 +130,16 @@ describe('POST /cloud/subscriptions/get-databases', () => { statusCode: 404, error: 'Not Found', }, - + }, + { + name: 'Should throw Unauthorized error when api key or secret was not provided', + headers: {}, + statusCode: 401, + responseBody: { + statusCode: 401, + error: 'Unauthorized', + message: 'Required authentication credentials were not provided', + }, }, ].map(mainCheckFn); }); diff --git a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_subscriptions.test.ts b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts similarity index 61% rename from redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_subscriptions.test.ts rename to redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts index a9519786b3..487276db1f 100644 --- a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_subscriptions.test.ts +++ b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts @@ -2,24 +2,17 @@ import { describe, deps, requirements, - generateInvalidDataTestCases, - validateInvalidDataTestCase, Joi, getMainCheckFn, serverConfig } from '../deps'; import { nock } from '../../helpers/test'; import { mockCloudApiSubscription, mockCloudSubscription } from 'src/__mocks__/cloud-autodiscovery'; const { request, server, constants } = deps; -const endpoint = () => request(server).post(`/cloud/autodiscovery/get-subscriptions`); +const endpoint = () => request(server).get(`/cloud/autodiscovery/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 headers = { + 'x-cloud-api-key': constants.TEST_CLOUD_API_KEY, + 'x-cloud-api-secret': constants.TEST_CLOUD_API_SECRET_KEY, } const responseSchema = Joi.array().items(Joi.object().keys({ @@ -35,15 +28,9 @@ const mainCheckFn = getMainCheckFn(endpoint); const nockScope = nock(serverConfig.get('redis_cloud').url); -describe('POST /cloud/autodiscovery/get-subscriptions', () => { +describe('GET /cloud/autodiscovery/subscriptions', () => { requirements('rte.serverType=local'); - describe('Validation', () => { - generateInvalidDataTestCases(dataSchema, validInputData).map( - validateInvalidDataTestCase(endpoint, dataSchema), - ); - }); - describe('Common', () => { [ { @@ -51,11 +38,8 @@ describe('POST /cloud/autodiscovery/get-subscriptions', () => { nockScope.get('/subscriptions') .reply(200, { subscriptions: [mockCloudApiSubscription] }); }, + headers, name: 'Should get subscriptions list', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, responseSchema, responseBody: [mockCloudSubscription], }, @@ -69,11 +53,8 @@ describe('POST /cloud/autodiscovery/get-subscriptions', () => { } }); }, - name: 'Should throw Forbidden error when api returned unauthorized error', - data: { - apiKey: constants.TEST_CLOUD_API_KEY, - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, + headers, + name: 'Should throw Forbidden error when api returned 403 error', statusCode: 403, responseBody: { statusCode: 403, @@ -90,17 +71,24 @@ describe('POST /cloud/autodiscovery/get-subscriptions', () => { } }); }, - name: 'Should throw Forbidden error when api key is incorrect', - data: { - apiKey: 'wrong-api-key', - apiSecretKey: constants.TEST_CLOUD_API_SECRET_KEY, - }, + name: 'Should throw Forbidden error when api returned 401', + headers, statusCode: 403, responseBody: { statusCode: 403, error: 'Forbidden', }, }, + { + name: 'Should throw Unauthorized error when api key or secret was not provided', + headers: {}, + statusCode: 401, + responseBody: { + statusCode: 401, + error: 'Unauthorized', + message: 'Required authentication credentials were not provided', + }, + }, ].map(mainCheckFn); }); }); diff --git a/redisinsight/api/test/helpers/test.ts b/redisinsight/api/test/helpers/test.ts index 386e22e153..62969a6f10 100644 --- a/redisinsight/api/test/helpers/test.ts +++ b/redisinsight/api/test/helpers/test.ts @@ -26,6 +26,7 @@ interface ITestCaseInput { endpoint: Function; // function that returns prepared supertest with url data?: any; attach?: any[]; + headers?: Record; fields?: [string, string][]; query?: any; statusCode?: number; @@ -43,6 +44,7 @@ interface ITestCaseInput { export const validateApiCall = async function ({ endpoint, data, + headers, attach, fields, query, @@ -58,6 +60,10 @@ export const validateApiCall = async function ({ request.send(typeof data === 'function' ? data() : data); } + if (headers) { + request.set(headers); + } + if (attach) { request.attach(...attach); } @@ -186,16 +192,19 @@ const badRequestCheckFn = (schema, data) => { * @param schema * @param validData * @param target + * @param extra */ export const generateInvalidDataTestCases = ( schema, validData, target = 'data', + extra: any = {}, ) => { return generateInvalidDataArray(schema).map(({ path, value }) => { return { name: `Validation error when ${target}: ${path.join('.')} = "${value}"`, [target]: path?.length ? set(cloneDeep(validData), path, value) : value, + ...extra, }; }); }; From 89a8a33e824b00a8f2a1afa41bffd84f96ba39a4 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 19 Jun 2023 10:04:12 +0300 Subject: [PATCH 3/9] #RI-4530 add fix databases endpoints + tests --- redisinsight/api/.jest.setup.ts | 7 + redisinsight/api/package.json | 1 + .../api/src/__mocks__/cloud-autodiscovery.ts | 73 ++++- .../cloud-autodiscovery.service.spec.ts | 252 +++++++++++++++--- .../cloud-autodiscovery.service.ts | 169 ++++++------ .../cloud.autodiscovery.controller.ts | 2 +- .../dto/add-cloud-database.dto.ts | 9 +- .../dto/get-cloud-databases.dto.ts | 28 +- .../get-cloud-subscription-database.dto.ts | 9 +- .../get-cloud-subscription-databases.dto.ts | 13 +- .../models/cloud-api.interface.ts | 12 +- .../autodiscovery/models/cloud-database.ts | 11 +- .../models/cloud-subscription.ts | 11 + .../utils/redis-cloud-converter.ts | 31 ++- ...-cloud-autodiscovery-subscriptions.test.ts | 26 +- ...POST-cloud-autodiscovery-databases.test.ts | 215 +++++++++++++++ ...cloud-autodiscovery-get_databases.test.ts} | 64 +++-- 17 files changed, 757 insertions(+), 176 deletions(-) create mode 100644 redisinsight/api/.jest.setup.ts create mode 100644 redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts rename redisinsight/api/test/api/cloud/{GET-cloud-autodiscovery-databases.test.ts => POST-cloud-autodiscovery-get_databases.test.ts} (56%) diff --git a/redisinsight/api/.jest.setup.ts b/redisinsight/api/.jest.setup.ts new file mode 100644 index 0000000000..9bb678998e --- /dev/null +++ b/redisinsight/api/.jest.setup.ts @@ -0,0 +1,7 @@ +// Workaround for @Type test coverage +jest.mock("class-transformer", () => { + return { + ...(jest.requireActual("class-transformer") as Object), + Type: (f: Function) => f() && jest.requireActual("class-transformer").Type(f), + }; +}); diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 3a3936e142..c4a1dde7f2 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -143,6 +143,7 @@ ".spec.ts$" ], "testEnvironment": "node", + "setupFilesAfterEnv": ["/../.jest.setup.ts"], "moduleNameMapper": { "src/(.*)": "/$1", "apiSrc/(.*)": "/$1", diff --git a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts index 082ed3621e..b346104bee 100644 --- a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts +++ b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts @@ -3,9 +3,15 @@ import { CloudDatabase, CloudDatabaseProtocol, CloudDatabaseStatus, CloudSubscription, - CloudSubscriptionStatus, ICloudApiAccount, ICloudApiDatabase, ICloudApiSubscription, + CloudSubscriptionStatus, CloudSubscriptionType, ICloudApiAccount, ICloudApiDatabase, ICloudApiSubscription, } from 'src/modules/cloud/autodiscovery/models'; -import { CloudAuthDto } from 'src/modules/cloud/autodiscovery/dto'; +import { + AddCloudDatabaseDto, AddCloudDatabaseResponse, + CloudAuthDto, + GetCloudSubscriptionDatabaseDto, + GetCloudSubscriptionDatabasesDto, +} from 'src/modules/cloud/autodiscovery/dto'; +import { ActionStatus } from 'src/common/models'; export const mockCloudApiAccount: ICloudApiAccount = { id: 40131, @@ -76,6 +82,7 @@ export const mockCloudApiSubscription: ICloudApiSubscription = { export const mockCloudSubscription = Object.assign(new CloudSubscription(), { id: mockCloudApiSubscription.id, + type: CloudSubscriptionType.Flexible, name: mockCloudApiSubscription.name, numberOfDatabases: mockCloudApiSubscription.numberOfDatabases, provider: mockCloudApiSubscription.cloudDetails[0].provider, @@ -83,6 +90,11 @@ export const mockCloudSubscription = Object.assign(new CloudSubscription(), { status: mockCloudApiSubscription.status, }); +export const mockCloudSubscriptionFixed = Object.assign(new CloudSubscription(), { + ...mockCloudSubscription, + type: CloudSubscriptionType.Fixed, +}); + export const mockCloudApiDatabase: ICloudApiDatabase = { databaseId: 50859754, name: 'bdb', @@ -135,6 +147,7 @@ export const mockCloudApiDatabase: ICloudApiDatabase = { export const mockCloudDatabase = Object.assign(new CloudDatabase(), { subscriptionId: mockCloudSubscription.id, + subscriptionType: CloudSubscriptionType.Flexible, databaseId: mockCloudApiDatabase.databaseId, name: mockCloudApiDatabase.name, publicEndpoint: mockCloudApiDatabase.publicEndpoint, @@ -152,6 +165,11 @@ export const mockCloudDatabase = Object.assign(new CloudDatabase(), { }, }); +export const mockCloudDatabaseFixed = Object.assign(new CloudDatabase(), { + ...mockCloudDatabase, + subscriptionType: CloudSubscriptionType.Fixed, +}); + export const mockCloudDatabaseFromList = Object.assign(new CloudDatabase(), { ...mockCloudDatabase, options: { @@ -160,7 +178,12 @@ export const mockCloudDatabaseFromList = Object.assign(new CloudDatabase(), { }, }); -export const mockCloudApiDatabases = { +export const mockCloudDatabaseFromListFixed = Object.assign(new CloudDatabase(), { + ...mockCloudDatabaseFromList, + subscriptionType: mockCloudDatabaseFixed.subscriptionType, +}); + +export const mockCloudApiSubscriptionDatabases = { accountId: mockCloudAccountInfo.accountId, subscription: [ { @@ -171,11 +194,55 @@ export const mockCloudApiDatabases = { ], }; +export const mockCloudApiSubscriptionDatabasesFixed = { + ...mockCloudApiSubscriptionDatabases, + subscription: mockCloudApiSubscriptionDatabases.subscription[0], +}; + export const mockCloudAuthDto: CloudAuthDto = { apiKey: 'api_key', apiSecret: 'api_secret_key', }; +export const mockGetCloudSubscriptionDatabasesDto = Object.assign(new GetCloudSubscriptionDatabasesDto(), { + subscriptionId: mockCloudSubscription.id, + subscriptionType: mockCloudSubscription.type, +}); + +export const mockGetCloudSubscriptionDatabasesDtoFixed = Object.assign(new GetCloudSubscriptionDatabasesDto(), { + subscriptionId: mockCloudSubscription.id, + subscriptionType: CloudSubscriptionType.Fixed, +}); + +export const mockGetCloudSubscriptionDatabaseDto = Object.assign(new GetCloudSubscriptionDatabaseDto(), { + subscriptionId: mockCloudSubscription.id, + subscriptionType: mockCloudSubscription.type, + databaseId: mockCloudDatabase.databaseId, +}); + +export const mockAddCloudDatabaseDto = Object.assign(new AddCloudDatabaseDto(), { + ...mockGetCloudSubscriptionDatabaseDto, +}); + +export const mockAddCloudDatabaseDtoFixed = Object.assign(new AddCloudDatabaseDto(), { + ...mockGetCloudSubscriptionDatabaseDto, + subscriptionType: CloudSubscriptionType.Fixed, +}); + +export const mockAddCloudDatabaseResponse = Object.assign(new AddCloudDatabaseResponse(), { + ...mockAddCloudDatabaseDto, + status: ActionStatus.Success, + message: 'Added', + databaseDetails: mockCloudDatabase, +}); + +export const mockAddCloudDatabaseResponseFixed = Object.assign(new AddCloudDatabaseResponse(), { + ...mockAddCloudDatabaseDtoFixed, + status: ActionStatus.Success, + message: 'Added', + databaseDetails: mockCloudDatabaseFixed, +}); + export const mockCloudAutodiscoveryAnalytics = jest.fn(() => ({ sendGetRECloudSubsSucceedEvent: jest.fn(), sendGetRECloudSubsFailedEvent: jest.fn(), diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts index a67a18cda6..a73917765a 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts @@ -1,18 +1,33 @@ import { Test, TestingModule } from '@nestjs/testing'; import axios, { AxiosError } from 'axios'; +import { ForbiddenException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { - ForbiddenException, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; -import { - mockCloudAccountInfo, mockCloudApiAccount, mockCloudApiDatabase, mockCloudApiDatabases, mockCloudApiSubscription, - mockCloudAuthDto, mockCloudAutodiscoveryAnalytics, mockCloudDatabase, mockCloudDatabaseFromList, - mockCloudSubscription, mockDatabaseService, MockType, + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + mockAddCloudDatabaseResponse, + mockAddCloudDatabaseResponseFixed, + mockCloudAccountInfo, + mockCloudApiAccount, + mockCloudApiDatabase, + mockCloudApiSubscription, + mockCloudApiSubscriptionDatabases, + mockCloudApiSubscriptionDatabasesFixed, + mockCloudAuthDto, + mockCloudAutodiscoveryAnalytics, + mockCloudDatabase, + mockCloudDatabaseFixed, + mockCloudDatabaseFromList, + mockCloudSubscription, + mockDatabaseService, + mockGetCloudSubscriptionDatabaseDto, + mockGetCloudSubscriptionDatabasesDto, + MockType } from 'src/__mocks__'; import { DatabaseService } from 'src/modules/database/database.service'; import { CloudAutodiscoveryService } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.service'; import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; +import { CloudDatabaseStatus, CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; +import { ActionStatus } from 'src/common/models'; const mockedAxios = axios as jest.Mocked; jest.mock('axios'); @@ -29,6 +44,7 @@ const mockApiUnauthenticatedResponse = { describe('CloudAutodiscoveryService', () => { let service: CloudAutodiscoveryService; let analytics: MockType; + let databaseService: MockType; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -47,6 +63,7 @@ describe('CloudAutodiscoveryService', () => { service = module.get(CloudAutodiscoveryService); analytics = module.get(CloudAutodiscoveryAnalytics); + databaseService = module.get(DatabaseService); }); describe('getAccount', () => { @@ -76,8 +93,23 @@ describe('CloudAutodiscoveryService', () => { }; mockedAxios.get.mockResolvedValue(response); - expect(await service.getSubscriptions(mockCloudAuthDto)).toEqual([mockCloudSubscription]); - expect(analytics.sendGetRECloudSubsSucceedEvent).toHaveBeenCalledWith([mockCloudSubscription]); + expect(await service.getSubscriptions(mockCloudAuthDto)).toEqual([{ + ...mockCloudSubscription, + type: CloudSubscriptionType.Fixed, + }, { + ...mockCloudSubscription, + type: CloudSubscriptionType.Flexible, + }]); + expect(analytics.sendGetRECloudSubsSucceedEvent) + .toHaveBeenCalledWith([{ + ...mockCloudSubscription, + type: CloudSubscriptionType.Fixed, + }]); + expect(analytics.sendGetRECloudSubsSucceedEvent) + .toHaveBeenCalledWith([{ + ...mockCloudSubscription, + type: CloudSubscriptionType.Flexible, + }]); }); it('should throw forbidden error when get subscriptions', async () => { mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); @@ -101,19 +133,16 @@ describe('CloudAutodiscoveryService', () => { data: mockCloudApiDatabase, }); - expect(await service.getSubscriptionDatabase(mockCloudAuthDto, { - subscriptionId: mockCloudSubscription.id, - databaseId: mockCloudDatabase.databaseId, - })).toEqual(mockCloudDatabase); + expect(await service.getSubscriptionDatabase( + mockCloudAuthDto, + mockGetCloudSubscriptionDatabaseDto, + )).toEqual(mockCloudDatabase); }); it('the user could not be authenticated', async () => { mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); await expect( - service.getSubscriptionDatabase(mockCloudAuthDto, { - subscriptionId: mockCloudSubscription.id, - databaseId: mockCloudDatabase.databaseId, - }), + service.getSubscriptionDatabase(mockCloudAuthDto, mockGetCloudSubscriptionDatabaseDto), ).rejects.toThrow(ForbiddenException); }); it('database not found', async () => { @@ -126,31 +155,35 @@ describe('CloudAutodiscoveryService', () => { mockedAxios.get.mockRejectedValue(apiResponse); await expect( - service.getSubscriptionDatabase(mockCloudAuthDto, { - subscriptionId: mockCloudSubscription.id, - databaseId: mockCloudDatabase.databaseId, - }), + service.getSubscriptionDatabase(mockCloudAuthDto, mockGetCloudSubscriptionDatabaseDto), ).rejects.toThrow(NotFoundException); }); }); describe('getSubscriptionDatabases', () => { - it('successfully get Redis Enterprise Cloud databases', async () => { + it('successfully get cloud databases', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockCloudApiSubscriptionDatabases, + }); + + expect(await service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDto)) + .toEqual([mockCloudDatabaseFromList]); + }); + it('successfully get cloud fixed databases', async () => { mockedAxios.get.mockResolvedValue({ status: 200, - data: mockCloudApiDatabases, + data: mockCloudApiSubscriptionDatabasesFixed, }); - expect(await service.getSubscriptionDatabases(mockCloudAuthDto, { - subscriptionId: mockCloudSubscription.id, - })).toEqual([mockCloudDatabaseFromList]); + expect(await service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDto)) + .toEqual([mockCloudDatabaseFromList]); }); it('the user could not be authenticated', async () => { mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); - await expect(service.getSubscriptionDatabases(mockCloudAuthDto, { - subscriptionId: mockCloudSubscription.id, - })).rejects.toThrow(ForbiddenException); + await expect(service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDto)) + .rejects.toThrow(ForbiddenException); }); it('subscription not found', async () => { mockedAxios.get.mockRejectedValue({ @@ -160,26 +193,52 @@ describe('CloudAutodiscoveryService', () => { }, }); - await expect(service.getSubscriptionDatabases(mockCloudAuthDto, { - subscriptionId: mockCloudSubscription.id, - })).rejects.toThrow(NotFoundException); + await expect(service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDto)) + .rejects.toThrow(NotFoundException); }); }); - describe('getDatabasesInMultipleSubscriptions', () => { + describe('getDatabases', () => { beforeEach(() => { service.getSubscriptionDatabases = jest.fn().mockResolvedValue([]); }); - it('should call getDatabasesInSubscription', async () => { + it('should call getSubscriptionDatabases 2 times', async () => { + await service.getDatabases(mockCloudAuthDto, { + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Flexible }, + ], + }); + + expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); + }); + it('should call getSubscriptionDatabases 2 times (different types)', async () => { + await service.getDatabases(mockCloudAuthDto, { + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Fixed }, + ], + }); + + expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); + }); + it('should call getSubscriptionDatabases 2 times (same id but different types)', async () => { await service.getDatabases(mockCloudAuthDto, { - subscriptionIds: [86070, 86071], + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Fixed }, + ], }); expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); }); - it('should not call getDatabasesInSubscription for duplicated ids', async () => { + it('should call getSubscriptionDatabases 2 times (uniq by id and type)', async () => { await service.getDatabases(mockCloudAuthDto, { - subscriptionIds: [86070, 86070, 86071], + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Fixed }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Fixed }, + ], }); expect(service.getSubscriptionDatabases).toHaveBeenCalledTimes(2); @@ -191,7 +250,10 @@ describe('CloudAutodiscoveryService', () => { await expect( service.getDatabases(mockCloudAuthDto, { - subscriptionIds: [86070, 86071], + subscriptions: [ + { subscriptionId: 86070, subscriptionType: CloudSubscriptionType.Flexible }, + { subscriptionId: 86071, subscriptionType: CloudSubscriptionType.Fixed }, + ], }), ).rejects.toThrow(NotFoundException); }); @@ -243,5 +305,117 @@ describe('CloudAutodiscoveryService', () => { expect(result).toBeInstanceOf(InternalServerErrorException); }); + it('should throw InternalServerErrorException with error from data', async () => { + const error = { + ...mockError, + message: 'Request failed with status code 500', + response: { + data: { + error: 'Service Unavailable', + }, + }, + }; + const result = service['getApiError'](error as AxiosError, title); + + expect(result).toBeInstanceOf(InternalServerErrorException); + }); + }); + + describe('addRedisCloudDatabases', () => { + let spy; + + beforeEach(() => { + spy = jest.spyOn(service, 'getSubscriptionDatabase'); + }); + + it('should successfully add 1 fixed and 1 flexible databases', async () => { + spy.mockResolvedValueOnce(mockCloudDatabase); + spy.mockResolvedValueOnce(mockCloudDatabaseFixed); + + const result = await service.addRedisCloudDatabases(mockCloudAuthDto, [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ]); + + expect(result).toEqual([ + mockAddCloudDatabaseResponse, + mockAddCloudDatabaseResponseFixed, + ]); + }); + + it('should successfully add 1 fixed database and report 1 error without database details (404)', async () => { + spy.mockRejectedValueOnce(new NotFoundException()); + spy.mockResolvedValueOnce(mockCloudDatabaseFixed); + + const result = await service.addRedisCloudDatabases(mockCloudAuthDto, [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ]); + + expect(result).toEqual([ + { + ...mockAddCloudDatabaseResponse, + error: { + message: 'Not Found', + statusCode: 404, + }, + message: 'Not Found', + status: 'fail', + databaseDetails: undefined, // no database details when database wasn't fetched from cloud + }, + mockAddCloudDatabaseResponseFixed, + ]); + }); + + it('should successfully add 1 fixed database and report 1 error with database details', async () => { + spy.mockResolvedValueOnce(mockCloudDatabase); + spy.mockResolvedValueOnce(mockCloudDatabaseFixed); + databaseService.create.mockRejectedValueOnce(new Error('Connectivity issue')); + + const result = await service.addRedisCloudDatabases(mockCloudAuthDto, [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ]); + + expect(result).toEqual([ + { + ...mockAddCloudDatabaseResponse, + message: 'Connectivity issue', + status: ActionStatus.Fail, + }, + mockAddCloudDatabaseResponseFixed, + ]); + }); + + it('should successfully add 1 fixed database and report 1 error if db is not actives', async () => { + spy.mockResolvedValueOnce({ + ...mockCloudDatabase, + status: CloudDatabaseStatus.Pending, + }); + spy.mockResolvedValueOnce(mockCloudDatabaseFixed); + + const result = await service.addRedisCloudDatabases(mockCloudAuthDto, [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ]); + + expect(result).toEqual([ + { + ...mockAddCloudDatabaseResponse, + error: { + error: 'Service Unavailable', + message: 'The base is inactive.', + statusCode: 503, + }, + message: 'The base is inactive.', + status: ActionStatus.Fail, + databaseDetails: { + ...mockAddCloudDatabaseResponse.databaseDetails, + status: CloudDatabaseStatus.Pending, + }, + }, + mockAddCloudDatabaseResponseFixed, + ]); + }); }); }); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts index 4ef1f39270..e358fd0c92 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts @@ -4,10 +4,11 @@ import { Injectable, InternalServerErrorException, Logger, - NotFoundException, ServiceUnavailableException, + NotFoundException, + ServiceUnavailableException, } from '@nestjs/common'; import axios, { AxiosError, AxiosResponse } from 'axios'; -import { uniq } from 'lodash'; +import { uniqBy } from 'lodash'; import config from 'src/utils/config'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { @@ -23,6 +24,7 @@ import { CloudDatabase, CloudDatabaseStatus, CloudSubscription, + CloudSubscriptionType, } from 'src/modules/cloud/autodiscovery/models'; import { DatabaseService } from 'src/modules/database/database.service'; import { HostingProvider } from 'src/modules/database/entities/database.entity'; @@ -30,8 +32,8 @@ import { ActionStatus } from 'src/common/models'; import { parseCloudAccountResponse, parseCloudDatabaseResponse, - parseCloudSubscriptionsResponse, parseCloudDatabasesInSubscriptionResponse, + parseCloudSubscriptionsResponse, } from 'src/modules/cloud/autodiscovery/utils/redis-cloud-converter'; import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; @@ -48,13 +50,22 @@ export class CloudAutodiscoveryService { private readonly analytics: CloudAutodiscoveryAnalytics, ) {} + /** + * Get api base for fixed subscriptions + * @param type + * @private + */ + getApiBase(type?: CloudSubscriptionType): string { + return `${this.config.url}${type === CloudSubscriptionType.Fixed ? '/fixed' : ''}`; + } + /** * Generates auth headers to attach to the request * @param apiKey * @param apiSecret * @private */ - static getAuthHeaders(apiKey: string, apiSecret: string) { + static getAuthHeaders({ apiKey, apiSecret }: CloudAuthDto) { return { 'x-api-key': apiKey, 'x-api-secret-key': apiSecret, @@ -99,12 +110,11 @@ export class CloudAutodiscoveryService { */ async getAccount(authDto: CloudAuthDto): Promise { this.logger.log('Getting cloud account.'); - const { apiKey, apiSecret } = authDto; try { const { data: { account }, }: AxiosResponse = await this.api.get(`${this.config.url}/`, { - headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecret), + headers: CloudAutodiscoveryService.getAuthHeaders(authDto), }); this.logger.log('Succeed to get RE cloud account.'); @@ -118,30 +128,37 @@ export class CloudAutodiscoveryService { /** * Get list of account subscriptions * @param authDto + * @param type */ - async getSubscriptions(authDto: CloudAuthDto): Promise { - this.logger.log('Getting RE cloud subscriptions.'); - const { apiKey, apiSecret } = authDto; + async getSubscriptionsByType(authDto: CloudAuthDto, type: CloudSubscriptionType): Promise { + this.logger.log(`Getting cloud ${type} subscriptions.`); try { const { data: { subscriptions }, }: AxiosResponse = await this.api.get( - `${this.config.url}/subscriptions`, + `${this.getApiBase(type)}/subscriptions`, { - headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecret), + headers: CloudAutodiscoveryService.getAuthHeaders(authDto), }, ); - this.logger.log('Succeed to get RE cloud subscriptions.'); - const result = parseCloudSubscriptionsResponse(subscriptions); + this.logger.log('Succeed to get cloud flexible subscriptions.'); + const result = parseCloudSubscriptionsResponse(subscriptions, type); this.analytics.sendGetRECloudSubsSucceedEvent(result); return result; } catch (error) { - const exception = this.getApiError(error, 'Failed to get RE cloud subscriptions'); + const exception = this.getApiError(error, 'Failed to get cloud flexible subscriptions'); this.analytics.sendGetRECloudSubsFailedEvent(exception); throw exception; } } + async getSubscriptions(authDto: CloudAuthDto): Promise { + return [].concat(...await Promise.all([ + this.getSubscriptionsByType(authDto, CloudSubscriptionType.Fixed), + this.getSubscriptionsByType(authDto, CloudSubscriptionType.Flexible), + ])); + } + /** * Get single database details * @param authDto @@ -151,20 +168,19 @@ export class CloudAutodiscoveryService { authDto: CloudAuthDto, dto: GetCloudSubscriptionDatabaseDto, ): Promise { - const { apiKey, apiSecret } = authDto; - const { subscriptionId, databaseId } = dto; + const { subscriptionId, databaseId, subscriptionType } = 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}`, + `${this.getApiBase(dto.subscriptionType)}/subscriptions/${subscriptionId}/databases/${databaseId}`, { - headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecret), + headers: CloudAutodiscoveryService.getAuthHeaders(authDto), }, ); this.logger.log('Succeed to get databases in RE cloud subscription.'); - return parseCloudDatabaseResponse(data, subscriptionId); + return parseCloudDatabaseResponse(data, subscriptionId, subscriptionType); } catch (error) { const { response } = error; if (response?.status === 404) { @@ -189,20 +205,19 @@ export class CloudAutodiscoveryService { authDto: CloudAuthDto, dto: GetCloudSubscriptionDatabasesDto, ): Promise { - const { apiKey, apiSecret } = authDto; - const { subscriptionId } = dto; + const { subscriptionId, subscriptionType } = 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`, + `${this.getApiBase(subscriptionType)}/subscriptions/${subscriptionId}/databases`, { - headers: CloudAutodiscoveryService.getAuthHeaders(apiKey, apiSecret), + headers: CloudAutodiscoveryService.getAuthHeaders(authDto), }, ); this.logger.log('Succeed to get databases in RE cloud subscription.'); - return parseCloudDatabasesInSubscriptionResponse(data); + return parseCloudDatabasesInSubscriptionResponse(data, subscriptionType); } catch (error) { const { response } = error; let exception: HttpException; @@ -231,15 +246,17 @@ export class CloudAutodiscoveryService { authDto: CloudAuthDto, dto: GetCloudDatabasesDto, ): Promise { - const subscriptionIds = uniq(dto.subscriptionIds); + const subscriptions = uniqBy( + dto.subscriptions, + ({ subscriptionId, subscriptionType }) => [subscriptionId, subscriptionType].join(), + ); + this.logger.log('Getting databases in RE cloud subscriptions.'); let result = []; try { await Promise.all( - subscriptionIds.map(async (subscriptionId: number) => { - const databases = await this.getSubscriptionDatabases(authDto, { - subscriptionId, - }); + subscriptions.map(async (subscription) => { + const databases = await this.getSubscriptionDatabases(authDto, subscription); result = [...result, ...databases]; }), ); @@ -256,63 +273,57 @@ export class CloudAutodiscoveryService { addDatabasesDto: AddCloudDatabaseDto[], ): Promise { this.logger.log('Adding Redis Cloud databases.'); - let result: AddCloudDatabaseResponse[]; - try { - result = await Promise.all( - addDatabasesDto.map( - async ( - dto: AddCloudDatabaseResponse, - ): Promise => { - const database = await this.getSubscriptionDatabase(authDto, { - ...dto, - }); - try { - const { - publicEndpoint, name, password, status, - } = database; - if (status !== CloudDatabaseStatus.Active) { - const exception = new ServiceUnavailableException(ERROR_MESSAGES.DATABASE_IS_INACTIVE); - return { - ...dto, - status: ActionStatus.Fail, - message: exception.message, - error: exception?.getResponse(), - databaseDetails: database, - }; - } - const [host, port] = publicEndpoint.split(':'); - await this.databaseService.create({ - host, - port: parseInt(port, 10), - name, - nameFromProvider: name, - password, - provider: HostingProvider.RE_CLOUD, - }); + return Promise.all( + addDatabasesDto.map( + async ( + dto: AddCloudDatabaseDto, + ): Promise => { + let database; + try { + database = await this.getSubscriptionDatabase(authDto, dto); - return { - ...dto, - status: ActionStatus.Success, - message: 'Added', - databaseDetails: database, - }; - } catch (error) { + const { + publicEndpoint, name, password, status, + } = database; + if (status !== CloudDatabaseStatus.Active) { + const exception = new ServiceUnavailableException(ERROR_MESSAGES.DATABASE_IS_INACTIVE); return { ...dto, status: ActionStatus.Fail, - message: error.message, - error: error?.response, + message: exception.message, + error: exception?.getResponse(), databaseDetails: database, }; } - }, - ), - ); - } catch (error) { - this.logger.error('Failed to add Redis Cloud databases.', error); - throw error; - } - return result; + const [host, port] = publicEndpoint.split(':'); + + await this.databaseService.create({ + host, + port: parseInt(port, 10), + name, + nameFromProvider: name, + password, + provider: HostingProvider.RE_CLOUD, + }); + + return { + ...dto, + status: ActionStatus.Success, + message: 'Added', + databaseDetails: database, + }; + } catch (error) { + return { + ...dto, + status: ActionStatus.Fail, + message: error.message, + error: error?.response, + databaseDetails: database, + }; + } + }, + ), + ); } } diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts index d11f885914..b8dfb6f888 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud.autodiscovery.controller.ts @@ -71,7 +71,7 @@ export class CloudAutodiscoveryController { return await this.service.getSubscriptions(authDto); } - @Get('databases') + @Post('get-databases') @UseInterceptors(ClassSerializerInterceptor) @ApiEndpoint({ description: 'Get databases belonging to subscriptions', diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts index 47447df006..fe63e3e837 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/add-cloud-database.dto.ts @@ -1,5 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsInt, IsNotEmpty } from 'class-validator'; +import { + IsDefined, IsEnum, IsInt, IsNotEmpty, +} from 'class-validator'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; export class AddCloudDatabaseDto { @ApiProperty({ @@ -11,6 +14,10 @@ export class AddCloudDatabaseDto { @IsInt({ always: true }) subscriptionId: number; + @IsEnum(CloudSubscriptionType) + @IsNotEmpty() + subscriptionType: CloudSubscriptionType; + @ApiProperty({ description: 'Database id', type: Number, diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts index f6ed5b985a..8b3cc75116 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-databases.dto.ts @@ -1,21 +1,21 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsInt } from 'class-validator'; -import { Transform, Type } from 'class-transformer'; +import { + ArrayNotEmpty, IsArray, IsNotEmpty, ValidateNested +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { + GetCloudSubscriptionDatabasesDto, +} from 'src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto'; export class GetCloudDatabasesDto { @ApiProperty({ - description: 'Subscription Ids', - type: Number, + description: 'Subscriptions where to discover databases', + type: GetCloudSubscriptionDatabasesDto, isArray: true, }) - @IsDefined() - @IsInt({ each: true }) - @Type(() => Number) - @Transform((value: number | number[]) => { - if (typeof value === 'number') { - return [value]; - } - return value; - }) - subscriptionIds: number[]; + @IsArray() + @ArrayNotEmpty() + @ValidateNested() + @Type(() => GetCloudSubscriptionDatabasesDto) + subscriptions: GetCloudSubscriptionDatabasesDto[]; } diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts index 410c964eb2..cd76fdfd7f 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-database.dto.ts @@ -1,6 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsInt, IsNotEmpty } from 'class-validator'; +import { + IsDefined, IsEnum, IsInt, IsNotEmpty, +} from 'class-validator'; import { Type } from 'class-transformer'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; export class GetCloudSubscriptionDatabaseDto { @ApiProperty({ @@ -13,6 +16,10 @@ export class GetCloudSubscriptionDatabaseDto { @Type(() => Number) subscriptionId: number; + @IsEnum(CloudSubscriptionType) + @IsNotEmpty() + subscriptionType: CloudSubscriptionType; + @ApiProperty({ description: 'Database Id', type: Number, diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts index 89a0803fc8..0b08d885c5 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/dto/get-cloud-subscription-databases.dto.ts @@ -1,6 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsDefined, IsInt, IsNotEmpty } from 'class-validator'; +import { + IsDefined, IsEnum, IsInt, IsNotEmpty, +} from 'class-validator'; import { Type } from 'class-transformer'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; export class GetCloudSubscriptionDatabasesDto { @ApiProperty({ @@ -12,4 +15,12 @@ export class GetCloudSubscriptionDatabasesDto { @IsInt({ always: true }) @Type(() => Number) subscriptionId: number; + + @ApiProperty({ + description: 'Subscription Id', + enum: CloudSubscriptionType, + }) + @IsEnum(CloudSubscriptionType) + @IsNotEmpty() + subscriptionType: CloudSubscriptionType; } diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts index 8bb02a92e4..1fd969deba 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts @@ -61,13 +61,15 @@ export interface ICloudApiDatabase { alerts: ICloudApiAlert[]; } +export interface ICloudApiSubscriptionDatabasesSubscription { + subscriptionId: number; + numberOfDatabases: number; + databases: ICloudApiDatabase[]; +} + export interface ICloudApiSubscriptionDatabases { accountId: number; - subscription: { - subscriptionId: number; - numberOfDatabases: number; - databases: ICloudApiDatabase[]; - }[]; + subscription: ICloudApiSubscriptionDatabasesSubscription | ICloudApiSubscriptionDatabasesSubscription[]; } // Account interfaces diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts index 722fce6d56..48e4181638 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Exclude } from 'class-transformer'; +import { Expose } from 'class-transformer'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models/cloud-subscription'; export enum CloudDatabaseProtocol { Redis = 'redis', @@ -37,6 +38,12 @@ export class CloudDatabase { }) subscriptionId: number; + @ApiProperty({ + description: 'Subscription type', + enum: CloudSubscriptionType, + }) + subscriptionType: CloudSubscriptionType; + @ApiProperty({ description: 'Database id', type: Number, @@ -81,6 +88,6 @@ export class CloudDatabase { }) options: any; - @Exclude() + @Expose({ groups: ['security'] }) password?: string; } diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts index cdf14c671c..8ec4e0c062 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-subscription.ts @@ -8,6 +8,11 @@ export enum CloudSubscriptionStatus { Error = 'error', } +export enum CloudSubscriptionType { + Flexible = 'flexible', + Fixed = 'fixed', +} + export class CloudSubscription { @ApiProperty({ description: 'Subscription id', @@ -21,6 +26,12 @@ export class CloudSubscription { }) name: string; + @ApiProperty({ + description: 'Subscription type', + enum: CloudSubscriptionType, + }) + type: CloudSubscriptionType; + @ApiProperty({ description: 'Number of databases in subscription', type: Number, diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts index 14a96e5db5..fc13a63453 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts @@ -1,10 +1,10 @@ import { RE_CLOUD_MODULES_NAMES } from 'src/constants'; -import { get, find } from 'lodash'; +import { get, find, isArray } from 'lodash'; import { CloudAccountInfo, CloudDatabase, CloudDatabaseMemoryStorage, CloudDatabasePersistencePolicy, CloudDatabaseProtocol, - CloudSubscription, + CloudSubscription, CloudSubscriptionType, } from 'src/modules/cloud/autodiscovery/models'; import { plainToClass } from 'class-transformer'; @@ -19,12 +19,16 @@ export const parseCloudAccountResponse = (account: any): CloudAccountInfo => pla ownerEmail: get(account, ['key', 'owner', 'email']), }); -export const parseCloudSubscriptionsResponse = (subscriptions: any[]): CloudSubscription[] => { +export const parseCloudSubscriptionsResponse = ( + subscriptions: any[], + type: CloudSubscriptionType, +): CloudSubscription[] => { const result: CloudSubscription[] = []; if (subscriptions?.length) { subscriptions.forEach((subscription): void => { result.push(plainToClass(CloudSubscription, { id: subscription.id, + type, name: subscription.name, numberOfDatabases: subscription.numberOfDatabases, status: subscription.status, @@ -42,13 +46,18 @@ export const parseCloudSubscriptionsResponse = (subscriptions: any[]): CloudSubs return result; }; -export const parseCloudDatabaseResponse = (database: any, subscriptionId: number): CloudDatabase => { +export const parseCloudDatabaseResponse = ( + database: any, + subscriptionId: number, + subscriptionType: CloudSubscriptionType, +): CloudDatabase => { const { databaseId, name, publicEndpoint, status, security, } = database; return plainToClass(CloudDatabase, { subscriptionId, + subscriptionType, databaseId, name, publicEndpoint, @@ -68,7 +77,7 @@ export const parseCloudDatabaseResponse = (database: any, subscriptionId: number enabledClustering: database.clustering.numberOfShards > 1, isReplicaDestination: !!database.replicaOf, }, - }); + }, { groups: ['security'] }); }; export const findReplicasForDatabase = (databases: any[], sourceDatabaseId: number): any[] => { @@ -94,18 +103,24 @@ export const findReplicasForDatabase = (databases: any[], sourceDatabaseId: numb }); }; -export const parseCloudDatabasesInSubscriptionResponse = (response: any): CloudDatabase[] => { - const subscription = response.subscription[0]; +export const parseCloudDatabasesInSubscriptionResponse = ( + response: any, + subscriptionType: CloudSubscriptionType, +): CloudDatabase[] => { + const subscription = isArray(response.subscription) ? response.subscription[0] : response.subscription; + const { subscriptionId, databases } = subscription; + let result: CloudDatabase[] = []; databases.forEach((database): void => { // We do not send the databases which have 'memcached' as their protocol. if (database.protocol === CloudDatabaseProtocol.Redis) { - result.push(parseCloudDatabaseResponse(database, subscriptionId)); + result.push(parseCloudDatabaseResponse(database, subscriptionId, subscriptionType)); } }); result = result.map((database) => ({ ...database, + subscriptionType, options: { ...database.options, isReplicaSource: !!findReplicasForDatabase( diff --git a/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts index 487276db1f..d891031868 100644 --- a/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts +++ b/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-subscriptions.test.ts @@ -5,7 +5,11 @@ import { Joi, getMainCheckFn, serverConfig } from '../deps'; import { nock } from '../../helpers/test'; -import { mockCloudApiSubscription, mockCloudSubscription } from 'src/__mocks__/cloud-autodiscovery'; +import { + mockCloudApiSubscription, + mockCloudSubscription, + mockCloudSubscriptionFixed +} from 'src/__mocks__/cloud-autodiscovery'; const { request, server, constants } = deps; const endpoint = () => request(server).get(`/cloud/autodiscovery/subscriptions`); @@ -22,6 +26,7 @@ const responseSchema = Joi.array().items(Joi.object().keys({ status: Joi.string().required(), provider: Joi.string(), region: Joi.string(), + type: Joi.string(), })).required(); const mainCheckFn = getMainCheckFn(endpoint); @@ -35,17 +40,23 @@ describe('GET /cloud/autodiscovery/subscriptions', () => { [ { before: () => { - nockScope.get('/subscriptions') + nockScope + .get('/fixed/subscriptions') + .reply(200, { subscriptions: [mockCloudApiSubscription] }) + .get('/subscriptions') .reply(200, { subscriptions: [mockCloudApiSubscription] }); }, headers, name: 'Should get subscriptions list', responseSchema, - responseBody: [mockCloudSubscription], + responseBody: [mockCloudSubscriptionFixed, mockCloudSubscription], }, { before: () => { - nockScope.get('/subscriptions') + nockScope + .get('/fixed/subscriptions') + .reply(200, { subscriptions: [mockCloudApiSubscription] }) + .get('/subscriptions') .reply(403, { response: { status: 403, @@ -63,13 +74,16 @@ describe('GET /cloud/autodiscovery/subscriptions', () => { }, { before: () => { - nockScope.get('/subscriptions') + nockScope + .get('/fixed/subscriptions') .reply(401, { response: { status: 401, data: '', } - }); + }) + .get('/subscriptions') + .reply(200, { subscriptions: [mockCloudApiSubscription] }); }, name: 'Should throw Forbidden error when api returned 401', headers, diff --git a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts new file mode 100644 index 0000000000..a6a75b223d --- /dev/null +++ b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts @@ -0,0 +1,215 @@ +import { + describe, + deps, + expect, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + Joi, getMainCheckFn, serverConfig, +} from '../deps'; +import { nock } from '../../helpers/test'; +import { + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + mockCloudApiDatabase, mockCloudDatabase, mockCloudDatabaseFixed, +} from 'src/__mocks__/cloud-autodiscovery'; +const { request, server, constants } = deps; + +const endpoint = () => request(server).post(`/cloud/autodiscovery/databases`); + +const dataSchema = Joi.object({ + databases: Joi.array().items({ + databaseId: Joi.number().allow(true).required().label('.databaseId'), + subscriptionId: Joi.number().allow(true).required().label('.subscriptionId'), + subscriptionType: Joi.string().valid('fixed', 'flexible').required().label('subscriptionType'), + }).required().messages({ + 'any.required': '{#label} should not be empty', + 'array.sparse': '{#label} must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), +}).strict(); + +const validInputData = { + databases: [{ + databaseId: 1, + subscriptionId: constants.TEST_CLOUD_SUBSCRIPTION_ID, + subscriptionType: 'fixed', + }] +} + +const headers = { + 'x-cloud-api-key': constants.TEST_CLOUD_API_KEY, + 'x-cloud-api-secret': constants.TEST_CLOUD_API_SECRET_KEY, +} + +const responseSchema = Joi.array().items(Joi.object().keys({ + subscriptionId: Joi.number().required(), + subscriptionType: Joi.string().valid('fixed', 'flexible').required(), + databaseId: Joi.number().required(), + status: Joi.string().valid('fail', 'success').required(), + message: Joi.string().required(), + databaseDetails: Joi.object().required(), +})).required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +const nockScope = nock(serverConfig.get('redis_cloud').url); + +describe('POST /cloud/subscriptions/databases', () => { + requirements('rte.serverType=local'); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData, 'data', { headers }).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common mocked to localhost', () => { + requirements('rte.type=STANDALONE', '!rte.pass', '!rte.tls'); + [ + { + before: () => { + nockScope + .get(`/subscriptions/${mockAddCloudDatabaseDto.subscriptionId}/databases/${mockAddCloudDatabaseDto.databaseId}`) + .reply(200, { + ...mockCloudApiDatabase, + publicEndpoint: 'localhost:6379', + }) + .get(`/fixed/subscriptions/${mockAddCloudDatabaseDtoFixed.subscriptionId}/databases/${mockAddCloudDatabaseDtoFixed.databaseId}`) + .reply(200, { + ...mockCloudApiDatabase, + publicEndpoint: 'localhost:6379', + }); + }, + name: 'Should add 2 databases', + data: { + databases: [ + mockAddCloudDatabaseDto, + mockAddCloudDatabaseDtoFixed, + ] + }, + headers, + responseSchema, + statusCode: 201, + checkFn: ({ body }) => { + expect(body).to.deep.eq([{ + ...mockAddCloudDatabaseDto, + message: 'Added', + status: 'success', + databaseDetails: { + ...mockCloudDatabase, + publicEndpoint: 'localhost:6379', + } + }, { + ...mockAddCloudDatabaseDtoFixed, + message: 'Added', + status: 'success', + databaseDetails: { + ...mockCloudDatabaseFixed, + publicEndpoint: 'localhost:6379', + } + }]); + }, + }, + ].map(mainCheckFn); + }); + + describe('Common fails', async () => { + [ + { + before: () => { + nockScope + .get(`/fixed/subscriptions/${mockAddCloudDatabaseDtoFixed.subscriptionId}/databases/${mockAddCloudDatabaseDtoFixed.databaseId}`) + .reply(403, { + response: { + status: 403, + data: { message: 'Unauthorized for this action' }, + } + }); + }, + name: 'Should throw Forbidden error when api returns 403', + headers, + data: { + databases: [ + mockAddCloudDatabaseDtoFixed, + ], + }, + responseBody: [{ + ...mockAddCloudDatabaseDtoFixed, + status: 'fail', + message: 'Error fetching account details.', + error: { + statusCode: 403, + error: 'Forbidden', + message: 'Error fetching account details.', + }, + }], + }, + { + before: () => { + nockScope.get(`/subscriptions/${mockAddCloudDatabaseDto.subscriptionId}/databases/${mockAddCloudDatabaseDto.databaseId}`) + .reply(401, { + response: { + status: 401, + data: '', + } + }); + }, + name: 'Should throw Forbidden error when api returns 401', + headers, + data: { + databases: [ + mockAddCloudDatabaseDto, + ], + }, + responseBody: [{ + ...mockAddCloudDatabaseDto, + status: 'fail', + message: 'Error fetching account details.', + error: { + statusCode: 403, + error: 'Forbidden', + message: 'Error fetching account details.', + }, + }], + }, + { + before: () => { + nockScope.get(`/subscriptions/${mockAddCloudDatabaseDto.subscriptionId}/databases/${mockAddCloudDatabaseDto.databaseId}`) + .reply(404, { + response: { + status: 404, + data: 'Database was not found', + } + }); + }, + name: 'Should throw Not Found error when subscription id is not found', + headers, + data: { + databases: [ + mockAddCloudDatabaseDto, + ], + }, + responseBody: [{ + ...mockAddCloudDatabaseDto, + status: 'fail', + message: 'Not Found', + error: { + statusCode: 404, + message: 'Not Found', + }, + }], + }, + { + name: 'Should throw Unauthorized error when api key or secret was not provided', + headers: {}, + statusCode: 401, + responseBody: { + statusCode: 401, + error: 'Unauthorized', + message: 'Required authentication credentials were not provided', + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-databases.test.ts b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts similarity index 56% rename from redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-databases.test.ts rename to redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts index 23a7842165..0f1a0cd60b 100644 --- a/redisinsight/api/test/api/cloud/GET-cloud-autodiscovery-databases.test.ts +++ b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts @@ -5,20 +5,37 @@ import { requirements, generateInvalidDataTestCases, validateInvalidDataTestCase, - Joi, getMainCheckFn, serverConfig + Joi, getMainCheckFn, serverConfig, } from '../deps'; import { nock } from '../../helpers/test'; -import { mockCloudApiDatabases, mockCloudDatabaseFromList } from 'src/__mocks__/cloud-autodiscovery'; +import { + mockCloudApiSubscriptionDatabases, + mockCloudApiSubscriptionDatabasesFixed, + mockCloudDatabaseFromList, + mockCloudDatabaseFromListFixed, + mockGetCloudSubscriptionDatabasesDto, + mockGetCloudSubscriptionDatabasesDtoFixed, +} from 'src/__mocks__/cloud-autodiscovery'; const { request, server, constants } = deps; -const endpoint = () => request(server).get(`/cloud/autodiscovery/databases`); +const endpoint = () => request(server).post(`/cloud/autodiscovery/get-databases`); const dataSchema = Joi.object({ - subscriptionIds: Joi.number().allow(true).required(), // todo: review transform rules + subscriptions: Joi.array().items({ + subscriptionId: Joi.number().allow(true).required().label('.subscriptionId'), // todo: review transform rules + subscriptionType: Joi.string().valid('fixed', 'flexible').required().label('subscriptionType'), + }).required().messages({ + 'any.required': '{#label} should not be empty', + 'array.sparse': '{#label} must be either object or array', + 'array.base': 'property {#label} must be either object or array', + }), }).strict(); const validInputData = { - subscriptionIds: 1 + subscriptions: [{ + subscriptionId: constants.TEST_CLOUD_SUBSCRIPTION_ID, + subscriptionType: 'fixed', + }] } const headers = { @@ -28,6 +45,7 @@ const headers = { const responseSchema = Joi.array().items(Joi.object().keys({ subscriptionId: Joi.number().required(), + subscriptionType: Joi.string().valid('fixed', 'flexible').required(), databaseId: Joi.number().required(), name: Joi.string().required(), publicEndpoint: Joi.string().required(), @@ -41,7 +59,7 @@ const mainCheckFn = getMainCheckFn(endpoint); const nockScope = nock(serverConfig.get('redis_cloud').url); -describe('GET /cloud/subscriptions/databases', () => { +describe('POST /cloud/subscriptions/get-databases', () => { requirements('rte.serverType=local'); describe('Validation', () => { @@ -54,23 +72,37 @@ describe('GET /cloud/subscriptions/databases', () => { [ { before: () => { - nockScope.get(`/subscriptions/${constants.TEST_CLOUD_SUBSCRIPTION_ID}/databases`) - .reply(200, mockCloudApiDatabases); + nockScope.get(`/subscriptions/${mockGetCloudSubscriptionDatabasesDto.subscriptionId}/databases`) + .reply(200, mockCloudApiSubscriptionDatabases); }, name: 'Should get databases list inside subscription', data: { - subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] + subscriptions: [mockGetCloudSubscriptionDatabasesDto] }, headers, responseSchema, checkFn: ({ body }) => { - expect(body).to.deep.eq([mockCloudDatabaseFromList]); }, }, { before: () => { - nockScope.get(`/subscriptions/${constants.TEST_CLOUD_SUBSCRIPTION_ID}/databases`) + nockScope.get(`/fixed/subscriptions/${mockGetCloudSubscriptionDatabasesDtoFixed.subscriptionId}/databases`) + .reply(200, mockCloudApiSubscriptionDatabasesFixed); + }, + name: 'Should get databases list inside fixed subscription', + data: { + subscriptions: [mockGetCloudSubscriptionDatabasesDtoFixed] + }, + headers, + responseSchema, + checkFn: ({ body }) => { + expect(body).to.deep.eq([mockCloudDatabaseFromListFixed]); + }, + }, + { + before: () => { + nockScope.get(`/subscriptions/${mockGetCloudSubscriptionDatabasesDto.subscriptionId}/databases`) .reply(403, { response: { status: 403, @@ -81,7 +113,7 @@ describe('GET /cloud/subscriptions/databases', () => { name: 'Should throw Forbidden error when api returns 403', headers, data: { - subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] + subscriptions: [mockGetCloudSubscriptionDatabasesDto] }, statusCode: 403, responseBody: { @@ -91,7 +123,7 @@ describe('GET /cloud/subscriptions/databases', () => { }, { before: () => { - nockScope.get(`/subscriptions/${constants.TEST_CLOUD_SUBSCRIPTION_ID}/databases`) + nockScope.get(`/subscriptions/${mockGetCloudSubscriptionDatabasesDto.subscriptionId}/databases`) .reply(401, { response: { status: 401, @@ -102,7 +134,7 @@ describe('GET /cloud/subscriptions/databases', () => { name: 'Should throw Forbidden error when api returns 401', headers, data: { - subscriptionIds: [constants.TEST_CLOUD_SUBSCRIPTION_ID] + subscriptions: [mockGetCloudSubscriptionDatabasesDto] }, statusCode: 403, responseBody: { @@ -112,7 +144,7 @@ describe('GET /cloud/subscriptions/databases', () => { }, { before: () => { - nockScope.get(`/subscriptions/${constants.TEST_CLOUD_SUBSCRIPTION_ID}/databases`) + nockScope.get(`/subscriptions/${mockGetCloudSubscriptionDatabasesDto.subscriptionId}/databases`) .reply(404, { response: { status: 404, @@ -123,7 +155,7 @@ describe('GET /cloud/subscriptions/databases', () => { name: 'Should throw Not Found error when subscription id is not found', headers, data: { - subscriptionIds: [1] + subscriptions: [mockGetCloudSubscriptionDatabasesDto] }, statusCode: 404, responseBody: { From e42b726963655a67fb4cae3c9759d586baa4c2d3 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 19 Jun 2023 12:23:59 +0300 Subject: [PATCH 4/9] #RI-4530 add cloud details --- redisinsight/api/config/ormconfig.ts | 2 + .../1687166457712-cloud-database-details.ts | 22 ++++++++ redisinsight/api/migration/index.ts | 2 + .../api/src/__mocks__/cloud-autodiscovery.ts | 21 ++++++++ redisinsight/api/src/__mocks__/databases.ts | 22 ++++++++ .../cloud-autodiscovery.service.spec.ts | 16 +++++- .../cloud-autodiscovery.service.ts | 1 + .../entities/cloud-database-details.entity.ts | 39 ++++++++++++++ .../models/cloud-api.interface.ts | 2 + .../models/cloud-database-details.ts | 51 +++++++++++++++++++ .../autodiscovery/models/cloud-database.ts | 16 +++++- .../cloud/autodiscovery/models/index.ts | 1 + .../utils/redis-cloud-converter.ts | 12 +++-- .../modules/database/database.service.spec.ts | 22 +++++++- .../database/dto/create.database.dto.ts | 14 ++++- .../database/entities/database.entity.ts | 14 +++++ .../src/modules/database/models/database.ts | 12 +++++ .../local.database.repository.spec.ts | 14 ++++- ...POST-cloud-autodiscovery-databases.test.ts | 8 +-- redisinsight/api/test/helpers/local-db.ts | 1 + 20 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 redisinsight/api/migration/1687166457712-cloud-database-details.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/entities/cloud-database-details.entity.ts create mode 100644 redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database-details.ts diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts index 04feb42f8e..00e722d087 100644 --- a/redisinsight/api/config/ormconfig.ts +++ b/redisinsight/api/config/ormconfig.ts @@ -17,6 +17,7 @@ import { BrowserHistoryEntity } from 'src/modules/browser/entities/browser-histo import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; import { FeatureEntity } from 'src/modules/feature/entities/feature.entity'; import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; +import { CloudDatabaseDetailsEntity } from 'src/modules/cloud/autodiscovery/entities/cloud-database-details.entity'; import migrations from '../migration'; import * as config from '../src/utils/config'; @@ -44,6 +45,7 @@ const ormConfig = { CustomTutorialEntity, FeatureEntity, FeaturesConfigEntity, + CloudDatabaseDetailsEntity, ], migrations, }; diff --git a/redisinsight/api/migration/1687166457712-cloud-database-details.ts b/redisinsight/api/migration/1687166457712-cloud-database-details.ts new file mode 100644 index 0000000000..f2622aec40 --- /dev/null +++ b/redisinsight/api/migration/1687166457712-cloud-database-details.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CloudDatabaseDetails1687166457712 implements MigrationInterface { + name = 'CloudDatabaseDetails1687166457712' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "database_cloud_details" ("id" varchar PRIMARY KEY NOT NULL, "cloudId" integer NOT NULL, "subscriptionType" varchar NOT NULL, "planMemoryLimit" integer, "memoryLimitMeasurementUnit" integer, "databaseId" varchar, CONSTRAINT "REL_f41ee5027391b3be8ad95e3d15" UNIQUE ("databaseId"))`); + await queryRunner.query(`CREATE TABLE "temporary_database_cloud_details" ("id" varchar PRIMARY KEY NOT NULL, "cloudId" integer NOT NULL, "subscriptionType" varchar NOT NULL, "planMemoryLimit" integer, "memoryLimitMeasurementUnit" integer, "databaseId" varchar, CONSTRAINT "REL_f41ee5027391b3be8ad95e3d15" UNIQUE ("databaseId"), CONSTRAINT "FK_f41ee5027391b3be8ad95e3d158" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_cloud_details"("id", "cloudId", "subscriptionType", "planMemoryLimit", "memoryLimitMeasurementUnit", "databaseId") SELECT "id", "cloudId", "subscriptionType", "planMemoryLimit", "memoryLimitMeasurementUnit", "databaseId" FROM "database_cloud_details"`); + await queryRunner.query(`DROP TABLE "database_cloud_details"`); + await queryRunner.query(`ALTER TABLE "temporary_database_cloud_details" RENAME TO "database_cloud_details"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_cloud_details" RENAME TO "temporary_database_cloud_details"`); + await queryRunner.query(`CREATE TABLE "database_cloud_details" ("id" varchar PRIMARY KEY NOT NULL, "cloudId" integer NOT NULL, "subscriptionType" varchar NOT NULL, "planMemoryLimit" integer, "memoryLimitMeasurementUnit" integer, "databaseId" varchar, CONSTRAINT "REL_f41ee5027391b3be8ad95e3d15" UNIQUE ("databaseId"))`); + await queryRunner.query(`INSERT INTO "database_cloud_details"("id", "cloudId", "subscriptionType", "planMemoryLimit", "memoryLimitMeasurementUnit", "databaseId") SELECT "id", "cloudId", "subscriptionType", "planMemoryLimit", "memoryLimitMeasurementUnit", "databaseId" FROM "temporary_database_cloud_details"`); + await queryRunner.query(`DROP TABLE "temporary_database_cloud_details"`); + await queryRunner.query(`DROP TABLE "database_cloud_details"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 11ae966a9d..66c46d5cf8 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -32,6 +32,7 @@ import { customTutorials1677135091633 } from './1677135091633-custom-tutorials'; import { databaseRecommendations1681900503586 } from './1681900503586-database-recommendations'; import { databaseRecommendationParams1683006064293 } from './1683006064293-database-recommendation-params'; import { Feature1684931530343 } from './1684931530343-feature'; +import { CloudDatabaseDetails1687166457712 } from './1687166457712-cloud-database-details'; export default [ initialMigration1614164490968, @@ -68,4 +69,5 @@ export default [ databaseRecommendations1681900503586, databaseRecommendationParams1683006064293, Feature1684931530343, + CloudDatabaseDetails1687166457712, ]; diff --git a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts index b346104bee..da40ac1fc4 100644 --- a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts +++ b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts @@ -145,6 +145,12 @@ export const mockCloudApiDatabase: ICloudApiDatabase = { alerts: [], }; +export const mockCloudApiDatabaseFixed: ICloudApiDatabase = { + ...mockCloudApiDatabase, + planMemoryLimit: 256, + memoryLimitMeasurementUnit: 'MB', +}; + export const mockCloudDatabase = Object.assign(new CloudDatabase(), { subscriptionId: mockCloudSubscription.id, subscriptionType: CloudSubscriptionType.Flexible, @@ -163,11 +169,21 @@ export const mockCloudDatabase = Object.assign(new CloudDatabase(), { isReplicaDestination: true, persistencePolicy: 'none', }, + cloudDetails: { + cloudId: mockCloudApiDatabase.databaseId, + subscriptionType: CloudSubscriptionType.Flexible, + }, }); export const mockCloudDatabaseFixed = Object.assign(new CloudDatabase(), { ...mockCloudDatabase, subscriptionType: CloudSubscriptionType.Fixed, + cloudDetails: { + cloudId: mockCloudApiDatabase.databaseId, + subscriptionType: CloudSubscriptionType.Fixed, + planMemoryLimit: mockCloudApiDatabaseFixed.planMemoryLimit, + memoryLimitMeasurementUnit: mockCloudApiDatabaseFixed.memoryLimitMeasurementUnit, + }, }); export const mockCloudDatabaseFromList = Object.assign(new CloudDatabase(), { @@ -220,6 +236,11 @@ export const mockGetCloudSubscriptionDatabaseDto = Object.assign(new GetCloudSub databaseId: mockCloudDatabase.databaseId, }); +export const mockGetCloudSubscriptionDatabaseDtoFixed = Object.assign(new GetCloudSubscriptionDatabaseDto(), { + ...mockGetCloudSubscriptionDatabaseDto, + subscriptionType: mockCloudSubscriptionFixed.type, +}); + export const mockAddCloudDatabaseDto = Object.assign(new AddCloudDatabaseDto(), { ...mockGetCloudSubscriptionDatabaseDto, }); diff --git a/redisinsight/api/src/__mocks__/databases.ts b/redisinsight/api/src/__mocks__/databases.ts index c4a567f60d..e45b1ee22e 100644 --- a/redisinsight/api/src/__mocks__/databases.ts +++ b/redisinsight/api/src/__mocks__/databases.ts @@ -15,6 +15,8 @@ import { mockSshOptionsPrivateKey, mockSshOptionsPrivateKeyEntity, } from 'src/__mocks__/ssh'; +import { CloudDatabaseDetails, CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models'; +import { CloudDatabaseDetailsEntity } from 'src/modules/cloud/autodiscovery/entities/cloud-database-details.entity'; export const mockDatabaseId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-db-id'; @@ -37,11 +39,31 @@ export const mockDatabase = Object.assign(new Database(), { compressor: Compressor.NONE, }); +export const mockDatabaseCloudDetails = Object.assign(new CloudDatabaseDetails(), { + subscriptionType: CloudSubscriptionType.Fixed, + cloudId: 500001, + planMemoryLimit: 256, + memoryLimitMeasurementUnit: 'MB', +}); + +export const mockDatabaseWithCloudDetails = Object.assign(new Database(), { + ...mockDatabase, + cloudDetails: mockDatabaseCloudDetails, +}); + export const mockDatabaseEntity = Object.assign(new DatabaseEntity(), { ...mockDatabase, encryption: null, }); +export const mockDatabaseEntityWithCloudDetails = Object.assign(new DatabaseEntity(), { + ...mockDatabaseEntity, + cloudDetails: Object.assign(new CloudDatabaseDetailsEntity(), { + id: 'some-uuid', + ...mockDatabaseCloudDetails, + }), +}); + export const mockDatabaseWithSshBasic = Object.assign(new Database(), { ...mockDatabase, ssh: true, diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts index a73917765a..3599f0cf22 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts @@ -8,7 +8,7 @@ import { mockAddCloudDatabaseResponseFixed, mockCloudAccountInfo, mockCloudApiAccount, - mockCloudApiDatabase, + mockCloudApiDatabase, mockCloudApiDatabaseFixed, mockCloudApiSubscription, mockCloudApiSubscriptionDatabases, mockCloudApiSubscriptionDatabasesFixed, @@ -20,8 +20,9 @@ import { mockCloudSubscription, mockDatabaseService, mockGetCloudSubscriptionDatabaseDto, + mockGetCloudSubscriptionDatabaseDtoFixed, mockGetCloudSubscriptionDatabasesDto, - MockType + MockType, } from 'src/__mocks__'; import { DatabaseService } from 'src/modules/database/database.service'; import { CloudAutodiscoveryService } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.service'; @@ -138,6 +139,17 @@ describe('CloudAutodiscoveryService', () => { mockGetCloudSubscriptionDatabaseDto, )).toEqual(mockCloudDatabase); }); + it('successfully get fixed database from Redis Cloud subscriptions', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: mockCloudApiDatabaseFixed, + }); + + expect(await service.getSubscriptionDatabase( + mockCloudAuthDto, + mockGetCloudSubscriptionDatabaseDtoFixed, + )).toEqual(mockCloudDatabaseFixed); + }); it('the user could not be authenticated', async () => { mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts index e358fd0c92..674bca5654 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts @@ -305,6 +305,7 @@ export class CloudAutodiscoveryService { nameFromProvider: name, password, provider: HostingProvider.RE_CLOUD, + cloudDetails: database?.cloudDetails, }); return { diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/entities/cloud-database-details.entity.ts b/redisinsight/api/src/modules/cloud/autodiscovery/entities/cloud-database-details.entity.ts new file mode 100644 index 0000000000..a5cf501643 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/entities/cloud-database-details.entity.ts @@ -0,0 +1,39 @@ +import { + Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, +} from 'typeorm'; +import { Expose } from 'class-transformer'; +import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; + +@Entity('database_cloud_details') +export class CloudDatabaseDetailsEntity { + @Expose() + @PrimaryGeneratedColumn('uuid') + id: string; + + @Expose() + @Column({ nullable: false }) + cloudId: number; + + @Expose() + @Column({ nullable: false }) + subscriptionType: string; + + @Expose() + @Column({ nullable: true }) + planMemoryLimit: number; + + @Expose() + @Column({ nullable: true }) + memoryLimitMeasurementUnit: number; + + @OneToOne( + () => DatabaseEntity, + (database) => database.cloudDetails, + { + nullable: true, + onDelete: 'CASCADE', + }, + ) + @JoinColumn() + database: DatabaseEntity; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts index 1fd969deba..9bd3b51944 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-api.interface.ts @@ -59,6 +59,8 @@ export interface ICloudApiDatabase { security: ICloudApiDatabaseSecurity; modules: ICloudApiDatabaseModule[]; alerts: ICloudApiAlert[]; + planMemoryLimit?: number; + memoryLimitMeasurementUnit?: string; } export interface ICloudApiSubscriptionDatabasesSubscription { diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database-details.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database-details.ts new file mode 100644 index 0000000000..1195c13fa1 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database-details.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsNotEmpty, IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models/cloud-subscription'; + +export class CloudDatabaseDetails { + @ApiProperty({ + description: 'Database id from the cloud', + type: Number, + }) + @Expose() + @IsNotEmpty() + @IsInt({ always: true }) + cloudId: number; + + @ApiProperty({ + description: 'Subscription type', + enum: () => CloudSubscriptionType, + example: CloudSubscriptionType.Flexible, + }) + @Expose() + @IsNotEmpty() + @IsEnum(CloudSubscriptionType) + subscriptionType: CloudSubscriptionType; + + @ApiPropertyOptional({ + description: 'Plan memory limit', + type: Number, + example: 256, + }) + @Expose() + @IsOptional() + @IsNumber() + planMemoryLimit?: number; + + @ApiPropertyOptional({ + description: 'Memory limit units', + type: String, + example: 'MB', + }) + @Expose() + @IsOptional() + @IsString() + memoryLimitMeasurementUnit?: string; +} diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts index 48e4181638..89fbb84687 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Expose } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; import { CloudSubscriptionType } from 'src/modules/cloud/autodiscovery/models/cloud-subscription'; +import { CloudDatabaseDetails } from 'src/modules/cloud/autodiscovery/models/cloud-database-details'; export enum CloudDatabaseProtocol { Redis = 'redis', @@ -36,30 +37,35 @@ export class CloudDatabase { description: 'Subscription id', type: Number, }) + @Expose() subscriptionId: number; @ApiProperty({ description: 'Subscription type', enum: CloudSubscriptionType, }) + @Expose() subscriptionType: CloudSubscriptionType; @ApiProperty({ description: 'Database id', type: Number, }) + @Expose() databaseId: number; @ApiProperty({ description: 'Database name', type: String, }) + @Expose() name: string; @ApiProperty({ description: 'Address your Redis Cloud database is available on', type: String, }) + @Expose() publicEndpoint: string; @ApiProperty({ @@ -67,12 +73,14 @@ export class CloudDatabase { enum: CloudDatabaseStatus, default: CloudDatabaseStatus.Active, }) + @Expose() status: CloudDatabaseStatus; @ApiProperty({ description: 'Is ssl authentication enabled or not', type: Boolean, }) + @Expose() sslClientAuthentication: boolean; @ApiProperty({ @@ -80,14 +88,20 @@ export class CloudDatabase { type: String, isArray: true, }) + @Expose() modules: string[]; @ApiProperty({ description: 'Additional database options', type: Object, }) + @Expose() options: any; @Expose({ groups: ['security'] }) password?: string; + + @Expose() + @Type(() => CloudDatabaseDetails) + cloudDetails?: CloudDatabaseDetails; } diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts index d6ad7afb28..484cf1a428 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/index.ts @@ -1,4 +1,5 @@ export * from './cloud-account-info'; export * from './cloud-api.interface'; export * from './cloud-database'; +export * from './cloud-database-details'; export * from './cloud-subscription'; diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts index fc13a63453..3df2c2acf4 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts @@ -4,7 +4,7 @@ import { CloudAccountInfo, CloudDatabase, CloudDatabaseMemoryStorage, CloudDatabasePersistencePolicy, CloudDatabaseProtocol, - CloudSubscription, CloudSubscriptionType, + CloudSubscription, CloudSubscriptionType, ICloudApiDatabase, } from 'src/modules/cloud/autodiscovery/models'; import { plainToClass } from 'class-transformer'; @@ -47,12 +47,12 @@ export const parseCloudSubscriptionsResponse = ( }; export const parseCloudDatabaseResponse = ( - database: any, + database: ICloudApiDatabase, subscriptionId: number, subscriptionType: CloudSubscriptionType, ): CloudDatabase => { const { - databaseId, name, publicEndpoint, status, security, + databaseId, name, publicEndpoint, status, security, planMemoryLimit, memoryLimitMeasurementUnit, } = database; return plainToClass(CloudDatabase, { @@ -77,6 +77,12 @@ export const parseCloudDatabaseResponse = ( enabledClustering: database.clustering.numberOfShards > 1, isReplicaDestination: !!database.replicaOf, }, + cloudDetails: { + cloudId: databaseId, + subscriptionType, + planMemoryLimit, + memoryLimitMeasurementUnit, + }, }, { groups: ['security'] }); }; diff --git a/redisinsight/api/src/modules/database/database.service.spec.ts b/redisinsight/api/src/modules/database/database.service.spec.ts index ef997f6cfd..ae287505ca 100644 --- a/redisinsight/api/src/modules/database/database.service.spec.ts +++ b/redisinsight/api/src/modules/database/database.service.spec.ts @@ -5,8 +5,20 @@ import { omit, get, update } from 'lodash'; import { classToClass } from 'src/utils'; import { - mockDatabase, mockDatabaseAnalytics, mockDatabaseFactory, mockDatabaseInfoProvider, mockDatabaseRepository, - mockRedisService, MockType, mockRedisGeneralInfo, mockRedisConnectionFactory, mockDatabaseWithTls, mockDatabaseWithTlsAuth, mockDatabaseWithSshPrivateKey, mockSentinelDatabaseWithTlsAuth, + mockDatabase, + mockDatabaseAnalytics, + mockDatabaseFactory, + mockDatabaseInfoProvider, + mockDatabaseRepository, + mockRedisService, + MockType, + mockRedisGeneralInfo, + mockRedisConnectionFactory, + mockDatabaseWithTls, + mockDatabaseWithTlsAuth, + mockDatabaseWithSshPrivateKey, + mockSentinelDatabaseWithTlsAuth, + mockDatabaseWithCloudDetails, } from 'src/__mocks__'; import { DatabaseAnalytics } from 'src/modules/database/database.analytics'; import { DatabaseService } from 'src/modules/database/database.service'; @@ -113,6 +125,12 @@ describe('DatabaseService', () => { expect(analytics.sendInstanceAddedEvent).toHaveBeenCalledWith(mockDatabase, mockRedisGeneralInfo); expect(analytics.sendInstanceAddFailedEvent).not.toHaveBeenCalled(); }); + it('should create new database with cloud details and send analytics event', async () => { + databaseRepository.create.mockResolvedValueOnce(mockDatabaseWithCloudDetails); + expect(await service.create(mockDatabaseWithCloudDetails)).toEqual(mockDatabaseWithCloudDetails); + expect(analytics.sendInstanceAddedEvent).toHaveBeenCalledWith(mockDatabaseWithCloudDetails, mockRedisGeneralInfo); + expect(analytics.sendInstanceAddFailedEvent).not.toHaveBeenCalled(); + }); it('should not fail when collecting data for analytics event', async () => { redisConnectionFactory.createRedisConnection.mockRejectedValueOnce(new Error()); expect(await service.create(mockDatabase)).toEqual(mockDatabase); diff --git a/redisinsight/api/src/modules/database/dto/create.database.dto.ts b/redisinsight/api/src/modules/database/dto/create.database.dto.ts index f90e7933b2..0de6caf14e 100644 --- a/redisinsight/api/src/modules/database/dto/create.database.dto.ts +++ b/redisinsight/api/src/modules/database/dto/create.database.dto.ts @@ -15,6 +15,7 @@ import { clientCertTransformer } from 'src/modules/certificate/transformers/clie import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; import { sshOptionsTransformer } from 'src/modules/ssh/transformers/ssh-options.transformer'; +import { CloudDatabaseDetails } from 'src/modules/cloud/autodiscovery/models/cloud-database-details'; @ApiExtraModels( CreateCaCertificateDto, UseCaCertificateDto, @@ -23,7 +24,7 @@ import { sshOptionsTransformer } from 'src/modules/ssh/transformers/ssh-options. ) export class CreateDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', 'timeout', 'nameFromProvider', 'provider', - 'tls', 'tlsServername', 'verifyServerCert', 'sentinelMaster', 'ssh', 'compressor', + 'tls', 'tlsServername', 'verifyServerCert', 'sentinelMaster', 'ssh', 'compressor', 'cloudDetails', ] as const) { @ApiPropertyOptional({ description: 'CA Certificate', @@ -66,4 +67,15 @@ export class CreateDatabaseDto extends PickType(Database, [ @Type(sshOptionsTransformer) @ValidateNested() sshOptions?: CreateBasicSshOptionsDto | CreateCertSshOptionsDto; + + @ApiPropertyOptional({ + description: 'Cloud details', + type: CloudDatabaseDetails, + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(() => CloudDatabaseDetails) + @ValidateNested() + cloudDetails?: CloudDatabaseDetails; } diff --git a/redisinsight/api/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index a42cad5f23..b727b1472c 100644 --- a/redisinsight/api/src/modules/database/entities/database.entity.ts +++ b/redisinsight/api/src/modules/database/entities/database.entity.ts @@ -7,6 +7,7 @@ import { DataAsJsonString } from 'src/common/decorators'; import { Expose, Transform, Type } from 'class-transformer'; import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; +import { CloudDatabaseDetailsEntity } from 'src/modules/cloud/autodiscovery/entities/cloud-database-details.entity'; export enum HostingProvider { UNKNOWN = 'UNKNOWN', @@ -195,6 +196,19 @@ export class DatabaseEntity { @Type(() => SshOptionsEntity) sshOptions: SshOptionsEntity; + @Expose() + @OneToOne( + () => CloudDatabaseDetailsEntity, + (cloudDetails) => cloudDetails.database, + { + eager: true, + onDelete: 'CASCADE', + cascade: true, + }, + ) + @Type(() => CloudDatabaseDetailsEntity) + cloudDetails: CloudDatabaseDetailsEntity; + @Expose() @Column({ nullable: false, diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index 957daae8f6..613285c923 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -21,6 +21,7 @@ import { Endpoint } from 'src/common/models'; import { AdditionalRedisModule } from 'src/modules/database/models/additional.redis.module'; import { SshOptions } from 'src/modules/ssh/models/ssh-options'; import { Default } from 'src/common/decorators'; +import { CloudDatabaseDetails } from 'src/modules/cloud/autodiscovery/models/cloud-database-details'; const CONNECTIONS_CONFIG = config.get('connections'); @@ -256,6 +257,17 @@ export class Database { @ValidateNested() sshOptions?: SshOptions; + @ApiPropertyOptional({ + description: 'Cloud details', + type: CloudDatabaseDetails, + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(() => CloudDatabaseDetails) + @ValidateNested() + cloudDetails?: CloudDatabaseDetails; + @ApiPropertyOptional({ description: 'Database compressor', default: Compressor.NONE, diff --git a/redisinsight/api/src/modules/database/repositories/local.database.repository.spec.ts b/redisinsight/api/src/modules/database/repositories/local.database.repository.spec.ts index aa59a16810..049793d8f7 100644 --- a/redisinsight/api/src/modules/database/repositories/local.database.repository.spec.ts +++ b/redisinsight/api/src/modules/database/repositories/local.database.repository.spec.ts @@ -9,12 +9,12 @@ import { mockClusterDatabaseWithTlsAuth, mockClusterDatabaseWithTlsAuthEntity, mockDatabase, - mockDatabaseEntity, + mockDatabaseEntity, mockDatabaseEntityWithCloudDetails, mockDatabaseId, mockDatabasePasswordEncrypted, mockDatabasePasswordPlain, mockDatabaseSentinelMasterPasswordEncrypted, - mockDatabaseSentinelMasterPasswordPlain, + mockDatabaseSentinelMasterPasswordPlain, mockDatabaseWithCloudDetails, mockDatabaseWithSshBasic, mockDatabaseWithSshBasicEntity, mockDatabaseWithSshPrivateKey, @@ -247,6 +247,16 @@ describe('LocalDatabaseRepository', () => { expect(clientCertRepository.create).not.toHaveBeenCalled(); }); + it('should create standalone database with cloud details', async () => { + repository.save.mockResolvedValue(mockDatabaseEntityWithCloudDetails); + + const result = await service.create(mockDatabaseWithCloudDetails); + + expect(result).toEqual(mockDatabaseWithCloudDetails); + expect(caCertRepository.create).not.toHaveBeenCalled(); + expect(clientCertRepository.create).not.toHaveBeenCalled(); + }); + it('should create standalone database (with existing certificates)', async () => { repository.save.mockResolvedValueOnce(mockDatabaseWithTlsAuthEntity); diff --git a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts index a6a75b223d..0506acb612 100644 --- a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts +++ b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-databases.test.ts @@ -73,12 +73,12 @@ describe('POST /cloud/subscriptions/databases', () => { .get(`/subscriptions/${mockAddCloudDatabaseDto.subscriptionId}/databases/${mockAddCloudDatabaseDto.databaseId}`) .reply(200, { ...mockCloudApiDatabase, - publicEndpoint: 'localhost:6379', + publicEndpoint: `${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`, }) .get(`/fixed/subscriptions/${mockAddCloudDatabaseDtoFixed.subscriptionId}/databases/${mockAddCloudDatabaseDtoFixed.databaseId}`) .reply(200, { ...mockCloudApiDatabase, - publicEndpoint: 'localhost:6379', + publicEndpoint: `${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`, }); }, name: 'Should add 2 databases', @@ -98,7 +98,7 @@ describe('POST /cloud/subscriptions/databases', () => { status: 'success', databaseDetails: { ...mockCloudDatabase, - publicEndpoint: 'localhost:6379', + publicEndpoint: `${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`, } }, { ...mockAddCloudDatabaseDtoFixed, @@ -106,7 +106,7 @@ describe('POST /cloud/subscriptions/databases', () => { status: 'success', databaseDetails: { ...mockCloudDatabaseFixed, - publicEndpoint: 'localhost:6379', + publicEndpoint: `${constants.TEST_REDIS_HOST}:${constants.TEST_REDIS_PORT}`, } }]); }, diff --git a/redisinsight/api/test/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts index 051987af27..7b74d40ab5 100644 --- a/redisinsight/api/test/helpers/local-db.ts +++ b/redisinsight/api/test/helpers/local-db.ts @@ -19,6 +19,7 @@ export const repositories = { CUSTOM_TUTORIAL: 'CustomTutorialEntity', FEATURES_CONFIG: 'FeaturesConfigEntity', FEATURE: 'FeatureEntity', + CLOUD_DATABASE_DETAILS: 'CloudDatabaseDetailsEntity', } let localDbConnection; From 39779d0f65a005e283a64362996bf6d2be1b348e Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 19 Jun 2023 13:01:33 +0300 Subject: [PATCH 5/9] #RI-4530 fix tests --- .../api/src/__mocks__/cloud-autodiscovery.ts | 12 +++++++++--- .../POST-cloud-autodiscovery-get_databases.test.ts | 6 ++++++ redisinsight/api/test/api/database/constants.ts | 6 ++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts index da40ac1fc4..79beefadb3 100644 --- a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts +++ b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts @@ -195,8 +195,11 @@ export const mockCloudDatabaseFromList = Object.assign(new CloudDatabase(), { }); export const mockCloudDatabaseFromListFixed = Object.assign(new CloudDatabase(), { - ...mockCloudDatabaseFromList, - subscriptionType: mockCloudDatabaseFixed.subscriptionType, + ...mockCloudDatabaseFixed, + options: { + ...mockCloudDatabaseFixed.options, + isReplicaSource: false, + }, }); export const mockCloudApiSubscriptionDatabases = { @@ -212,7 +215,10 @@ export const mockCloudApiSubscriptionDatabases = { export const mockCloudApiSubscriptionDatabasesFixed = { ...mockCloudApiSubscriptionDatabases, - subscription: mockCloudApiSubscriptionDatabases.subscription[0], + subscription: { + ...mockCloudApiSubscriptionDatabases.subscription[0], + databases: [mockCloudApiDatabaseFixed], + }, }; export const mockCloudAuthDto: CloudAuthDto = { diff --git a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts index 0f1a0cd60b..26e850b2ef 100644 --- a/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts +++ b/redisinsight/api/test/api/cloud/POST-cloud-autodiscovery-get_databases.test.ts @@ -53,6 +53,12 @@ const responseSchema = Joi.array().items(Joi.object().keys({ sslClientAuthentication: Joi.boolean().required(), modules: Joi.array().required(), options: Joi.object().required(), + cloudDetails: Joi.object().keys({ + cloudId: Joi.number().required(), + subscriptionType: Joi.string().valid('fixed', 'flexible').required(), + planMemoryLimit: Joi.number(), + memoryLimitMeasurementUnit: Joi.string(), + }).required(), })).required(); const mainCheckFn = getMainCheckFn(endpoint); diff --git a/redisinsight/api/test/api/database/constants.ts b/redisinsight/api/test/api/database/constants.ts index f0a4962f43..74dd538197 100644 --- a/redisinsight/api/test/api/database/constants.ts +++ b/redisinsight/api/test/api/database/constants.ts @@ -45,4 +45,10 @@ export const databaseSchema = Joi.object().keys({ privateKey: Joi.string().allow(null), passphrase: Joi.string().allow(null), }).allow(null), + cloudDetails: Joi.object().keys({ + cloudId: Joi.number().required(), + subscriptionType: Joi.string().valid('fixed', 'flexible').required(), + planMemoryLimit: Joi.number(), + memoryLimitMeasurementUnit: Joi.string(), + }).allow(null), }); From 5b392df8005f768587890702a510ffd1fff6afe8 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 19 Jun 2023 13:17:38 +0300 Subject: [PATCH 6/9] #RI-4530 fix UTests --- .../autodiscovery/cloud-autodiscovery.service.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts index 3599f0cf22..38ba871833 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts @@ -16,12 +16,12 @@ import { mockCloudAutodiscoveryAnalytics, mockCloudDatabase, mockCloudDatabaseFixed, - mockCloudDatabaseFromList, + mockCloudDatabaseFromList, mockCloudDatabaseFromListFixed, mockCloudSubscription, mockDatabaseService, mockGetCloudSubscriptionDatabaseDto, mockGetCloudSubscriptionDatabaseDtoFixed, - mockGetCloudSubscriptionDatabasesDto, + mockGetCloudSubscriptionDatabasesDto, mockGetCloudSubscriptionDatabasesDtoFixed, MockType, } from 'src/__mocks__'; import { DatabaseService } from 'src/modules/database/database.service'; @@ -188,8 +188,8 @@ describe('CloudAutodiscoveryService', () => { data: mockCloudApiSubscriptionDatabasesFixed, }); - expect(await service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDto)) - .toEqual([mockCloudDatabaseFromList]); + expect(await service.getSubscriptionDatabases(mockCloudAuthDto, mockGetCloudSubscriptionDatabasesDtoFixed)) + .toEqual([mockCloudDatabaseFromListFixed]); }); it('the user could not be authenticated', async () => { mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); From 2e49430c9eb5bb350c42d44814f44d91e338ba74 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 19 Jun 2023 13:40:22 +0300 Subject: [PATCH 7/9] #RI-4530 Add telemetry --- .../cloud-autodicovery.analytics.spec.ts | 40 ++++++++++++++----- .../cloud-autodiscovery.analytics.ts | 13 ++++-- .../cloud-autodiscovery.service.ts | 4 +- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts index 1a50faa9c9..2cefbcc9a0 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodicovery.analytics.spec.ts @@ -3,8 +3,12 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { TelemetryEvents } from 'src/constants'; import { InternalServerErrorException } from '@nestjs/common'; import { CloudAutodiscoveryAnalytics } from 'src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics'; -import { CloudDatabaseStatus, CloudSubscriptionStatus } from 'src/modules/cloud/autodiscovery/models'; -import { mockCloudDatabase, mockCloudSubscription } from 'src/__mocks__'; +import { + CloudDatabaseStatus, + CloudSubscriptionStatus, + CloudSubscriptionType +} from 'src/modules/cloud/autodiscovery/models'; +import { mockCloudDatabase, mockCloudDatabaseFixed, mockCloudSubscription } from 'src/__mocks__'; describe('CloudAutodiscoveryAnalytics', () => { let service: CloudAutodiscoveryAnalytics; @@ -36,13 +40,14 @@ describe('CloudAutodiscoveryAnalytics', () => { service.sendGetRECloudSubsSucceedEvent([ mockCloudSubscription, mockCloudSubscription, - ]); + ], CloudSubscriptionType.Flexible); expect(sendEventMethod).toHaveBeenCalledWith( TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, { numberOfActiveSubscriptions: 2, totalNumberOfSubscriptions: 2, + type: CloudSubscriptionType.Flexible, }, ); }); @@ -53,13 +58,14 @@ describe('CloudAutodiscoveryAnalytics', () => { status: CloudSubscriptionStatus.Error, }, mockCloudSubscription, - ]); + ], CloudSubscriptionType.Flexible); expect(sendEventMethod).toHaveBeenCalledWith( TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, { numberOfActiveSubscriptions: 1, totalNumberOfSubscriptions: 2, + type: CloudSubscriptionType.Flexible, }, ); }); @@ -73,52 +79,56 @@ describe('CloudAutodiscoveryAnalytics', () => { ...mockCloudSubscription, status: CloudSubscriptionStatus.Error, }, - ]); + ], CloudSubscriptionType.Flexible); expect(sendEventMethod).toHaveBeenCalledWith( TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, { numberOfActiveSubscriptions: 0, totalNumberOfSubscriptions: 2, + type: CloudSubscriptionType.Flexible, }, ); }); it('should emit GetRECloudSubsSucceedEvent event for empty list', () => { - service.sendGetRECloudSubsSucceedEvent([]); + service.sendGetRECloudSubsSucceedEvent([], CloudSubscriptionType.Flexible); expect(sendEventMethod).toHaveBeenCalledWith( TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, { numberOfActiveSubscriptions: 0, totalNumberOfSubscriptions: 0, + type: CloudSubscriptionType.Flexible, }, ); }); it('should emit GetRECloudSubsSucceedEvent event for undefined input value', () => { - service.sendGetRECloudSubsSucceedEvent(undefined); + service.sendGetRECloudSubsSucceedEvent(undefined, CloudSubscriptionType.Fixed); expect(sendEventMethod).toHaveBeenCalledWith( TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, { numberOfActiveSubscriptions: 0, totalNumberOfSubscriptions: 0, + type: CloudSubscriptionType.Fixed, }, ); }); it('should not throw on error when sending GetRECloudSubsSucceedEvent event', () => { const input: any = {}; - expect(() => service.sendGetRECloudSubsSucceedEvent(input)).not.toThrow(); + expect(() => service.sendGetRECloudSubsSucceedEvent(input, CloudSubscriptionType.Flexible)).not.toThrow(); expect(sendEventMethod).not.toHaveBeenCalled(); }); }); describe('sendGetRECloudSubsFailedEvent', () => { it('should emit GetRECloudSubsFailedEvent event', () => { - service.sendGetRECloudSubsFailedEvent(httpException); + service.sendGetRECloudSubsFailedEvent(httpException, CloudSubscriptionType.Fixed); expect(sendFailedEventMethod).toHaveBeenCalledWith( TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, httpException, + { type: CloudSubscriptionType.Fixed }, ); }); }); @@ -127,7 +137,7 @@ describe('CloudAutodiscoveryAnalytics', () => { it('should emit event with active databases', () => { service.sendGetRECloudDbsSucceedEvent([ mockCloudDatabase, - mockCloudDatabase, + mockCloudDatabaseFixed, ]); expect(sendEventMethod).toHaveBeenCalledWith( @@ -135,6 +145,8 @@ describe('CloudAutodiscoveryAnalytics', () => { { numberOfActiveDatabases: 2, totalNumberOfDatabases: 2, + fixed: 1, + flexible: 1, }, ); }); @@ -152,6 +164,8 @@ describe('CloudAutodiscoveryAnalytics', () => { { numberOfActiveDatabases: 1, totalNumberOfDatabases: 2, + fixed: 0, + flexible: 2, }, ); }); @@ -168,6 +182,8 @@ describe('CloudAutodiscoveryAnalytics', () => { { numberOfActiveDatabases: 0, totalNumberOfDatabases: 1, + fixed: 0, + flexible: 1, }, ); }); @@ -179,6 +195,8 @@ describe('CloudAutodiscoveryAnalytics', () => { { numberOfActiveDatabases: 0, totalNumberOfDatabases: 0, + fixed: 0, + flexible: 0, }, ); }); @@ -190,6 +208,8 @@ describe('CloudAutodiscoveryAnalytics', () => { { numberOfActiveDatabases: 0, totalNumberOfDatabases: 0, + fixed: 0, + flexible: 0, }, ); }); diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts index d9075b0d8c..20008a8529 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.analytics.ts @@ -1,3 +1,4 @@ +import { countBy } from 'lodash'; import { HttpException, Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { TelemetryEvents } from 'src/constants'; @@ -6,7 +7,7 @@ import { CloudDatabase, CloudDatabaseStatus, CloudSubscription, - CloudSubscriptionStatus, + CloudSubscriptionStatus, CloudSubscriptionType, } from 'src/modules/cloud/autodiscovery/models'; @Injectable() @@ -15,7 +16,7 @@ export class CloudAutodiscoveryAnalytics extends TelemetryBaseService { super(eventEmitter); } - sendGetRECloudSubsSucceedEvent(subscriptions: CloudSubscription[] = []) { + sendGetRECloudSubsSucceedEvent(subscriptions: CloudSubscription[] = [], type: CloudSubscriptionType) { try { this.sendEvent( TelemetryEvents.RECloudSubscriptionsDiscoverySucceed, @@ -24,6 +25,7 @@ export class CloudAutodiscoveryAnalytics extends TelemetryBaseService { (sub) => sub.status === CloudSubscriptionStatus.Active, ).length, totalNumberOfSubscriptions: subscriptions.length, + type, }, ); } catch (e) { @@ -31,8 +33,8 @@ export class CloudAutodiscoveryAnalytics extends TelemetryBaseService { } } - sendGetRECloudSubsFailedEvent(exception: HttpException) { - this.sendFailedEvent(TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, exception); + sendGetRECloudSubsFailedEvent(exception: HttpException, type: CloudSubscriptionType) { + this.sendFailedEvent(TelemetryEvents.RECloudSubscriptionsDiscoveryFailed, exception, { type }); } sendGetRECloudDbsSucceedEvent(databases: CloudDatabase[] = []) { @@ -44,6 +46,9 @@ export class CloudAutodiscoveryAnalytics extends TelemetryBaseService { (db) => db.status === CloudDatabaseStatus.Active, ).length, totalNumberOfDatabases: databases.length, + fixed: 0, + flexible: 0, + ...countBy(databases, 'subscriptionType'), }, ); } catch (e) { diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts index 674bca5654..b4c74976d1 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.ts @@ -143,11 +143,11 @@ export class CloudAutodiscoveryService { ); this.logger.log('Succeed to get cloud flexible subscriptions.'); const result = parseCloudSubscriptionsResponse(subscriptions, type); - this.analytics.sendGetRECloudSubsSucceedEvent(result); + this.analytics.sendGetRECloudSubsSucceedEvent(result, type); return result; } catch (error) { const exception = this.getApiError(error, 'Failed to get cloud flexible subscriptions'); - this.analytics.sendGetRECloudSubsFailedEvent(exception); + this.analytics.sendGetRECloudSubsFailedEvent(exception, type); throw exception; } } From da5400d3cf069aa8358fbfeac382e5aed4a80689 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 19 Jun 2023 14:02:16 +0300 Subject: [PATCH 8/9] #RI-4530 Fix UTests --- .../cloud-autodiscovery.service.spec.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts index 38ba871833..4d2bf04981 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/cloud-autodiscovery.service.spec.ts @@ -17,7 +17,7 @@ import { mockCloudDatabase, mockCloudDatabaseFixed, mockCloudDatabaseFromList, mockCloudDatabaseFromListFixed, - mockCloudSubscription, + mockCloudSubscription, mockCloudSubscriptionFixed, mockDatabaseService, mockGetCloudSubscriptionDatabaseDto, mockGetCloudSubscriptionDatabaseDtoFixed, @@ -95,7 +95,7 @@ describe('CloudAutodiscoveryService', () => { mockedAxios.get.mockResolvedValue(response); expect(await service.getSubscriptions(mockCloudAuthDto)).toEqual([{ - ...mockCloudSubscription, + ...mockCloudSubscriptionFixed, type: CloudSubscriptionType.Fixed, }, { ...mockCloudSubscription, @@ -103,14 +103,12 @@ describe('CloudAutodiscoveryService', () => { }]); expect(analytics.sendGetRECloudSubsSucceedEvent) .toHaveBeenCalledWith([{ - ...mockCloudSubscription, - type: CloudSubscriptionType.Fixed, - }]); + ...mockCloudSubscriptionFixed, + }], CloudSubscriptionType.Fixed); expect(analytics.sendGetRECloudSubsSucceedEvent) .toHaveBeenCalledWith([{ ...mockCloudSubscription, - type: CloudSubscriptionType.Flexible, - }]); + }], CloudSubscriptionType.Flexible); }); it('should throw forbidden error when get subscriptions', async () => { mockedAxios.get.mockRejectedValue(mockApiUnauthenticatedResponse); @@ -123,7 +121,7 @@ describe('CloudAutodiscoveryService', () => { .toHaveBeenCalledWith(service['getApiError']( mockApiUnauthenticatedResponse as AxiosError, 'Failed to get RE cloud subscriptions', - )); + ), CloudSubscriptionType.Flexible); }); }); From 07a1b5c92fbe66195cef94a414e3379df047b81a Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 19 Jun 2023 15:31:30 +0300 Subject: [PATCH 9/9] #RI-4530 Add support for "stack" protocol --- redisinsight/api/src/__mocks__/cloud-autodiscovery.ts | 1 + .../src/modules/cloud/autodiscovery/models/cloud-database.ts | 1 + .../modules/cloud/autodiscovery/utils/redis-cloud-converter.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts index 79beefadb3..2f66f94316 100644 --- a/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts +++ b/redisinsight/api/src/__mocks__/cloud-autodiscovery.ts @@ -147,6 +147,7 @@ export const mockCloudApiDatabase: ICloudApiDatabase = { export const mockCloudApiDatabaseFixed: ICloudApiDatabase = { ...mockCloudApiDatabase, + protocol: CloudDatabaseProtocol.Stack, planMemoryLimit: 256, memoryLimitMeasurementUnit: 'MB', }; diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts index 89fbb84687..de6c727879 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/models/cloud-database.ts @@ -5,6 +5,7 @@ import { CloudDatabaseDetails } from 'src/modules/cloud/autodiscovery/models/clo export enum CloudDatabaseProtocol { Redis = 'redis', + Stack = 'stack', Memcached = 'memcached', } diff --git a/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts index 3df2c2acf4..cd9547aa6a 100644 --- a/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts +++ b/redisinsight/api/src/modules/cloud/autodiscovery/utils/redis-cloud-converter.ts @@ -120,7 +120,7 @@ export const parseCloudDatabasesInSubscriptionResponse = ( let result: CloudDatabase[] = []; databases.forEach((database): void => { // We do not send the databases which have 'memcached' as their protocol. - if (database.protocol === CloudDatabaseProtocol.Redis) { + if ([CloudDatabaseProtocol.Redis, CloudDatabaseProtocol.Stack].includes(database.protocol)) { result.push(parseCloudDatabaseResponse(database, subscriptionId, subscriptionType)); } });