diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index cd2f6e8b24..4dd93f465a 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -88,6 +88,7 @@ export default { requestTimeout: parseInt(process.env.RI_REQUEST_TIMEOUT, 10) || 25000, excludeRoutes: [], excludeAuthRoutes: [], + databaseManagement: process.env.RI_DATABASE_MANAGEMENT !== 'false', }, statics: { initDefaults: process.env.RI_STATICS_INIT_DEFAULTS ? process.env.RI_STATICS_INIT_DEFAULTS === 'true' : true, diff --git a/redisinsight/api/src/__mocks__/database-import.ts b/redisinsight/api/src/__mocks__/database-import.ts index 20508245ad..2266898dbf 100644 --- a/redisinsight/api/src/__mocks__/database-import.ts +++ b/redisinsight/api/src/__mocks__/database-import.ts @@ -95,3 +95,7 @@ export const mockSshImportService = jest.fn(() => ({ id: undefined, }), })); + +export const mockDatabaseImportService = jest.fn(() => ({ + import: jest.fn(), +})); diff --git a/redisinsight/api/src/__mocks__/databases.ts b/redisinsight/api/src/__mocks__/databases.ts index 88fc98b175..406a1d7acb 100644 --- a/redisinsight/api/src/__mocks__/databases.ts +++ b/redisinsight/api/src/__mocks__/databases.ts @@ -18,6 +18,7 @@ import { CloudDatabaseDetailsEntity } from 'src/modules/cloud/database/entities/ import { mockCloudDatabaseDetails, mockCloudDatabaseDetailsEntity } from 'src/__mocks__/cloud-database'; import { mockRedisClientListResult } from 'src/__mocks__/database-info'; import { DatabaseOverviewKeyspace } from 'src/modules/database/constants/overview'; +import { CreateDatabaseDto } from 'src/modules/database/dto/create.database.dto'; export const mockDatabaseId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-db-id'; @@ -43,6 +44,12 @@ export const mockDatabase = Object.assign(new Database(), { version: '7.0', }); +export const mockCreateDatabaseDto = Object.assign(new CreateDatabaseDto(), { + name: mockDatabase.name, + host: mockDatabase.host, + port: mockDatabase.port, +}); + export const mockDatabaseModules = [ { name: 'rg', @@ -267,6 +274,11 @@ export const mockDatabaseRepository = jest.fn(() => ({ export const mockDatabaseService = jest.fn(() => ({ get: jest.fn().mockResolvedValue(mockDatabase), create: jest.fn().mockResolvedValue(mockDatabase), + update: jest.fn().mockResolvedValue(mockDatabase), + clone: jest.fn().mockResolvedValue(mockDatabase), + testConnection: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + bulkDelete: jest.fn().mockResolvedValue({ affected: 0 }), list: jest.fn(), })); diff --git a/redisinsight/api/src/__mocks__/feature.ts b/redisinsight/api/src/__mocks__/feature.ts index 82b2564a3f..4486e35b09 100644 --- a/redisinsight/api/src/__mocks__/feature.ts +++ b/redisinsight/api/src/__mocks__/feature.ts @@ -184,6 +184,11 @@ export const mockFeatureSso = Object.assign(new Feature(), { }, }); +export const mockFeatureDatabaseManagement = Object.assign(new Feature(), { + name: KnownFeatures.DatabaseManagement, + flag: true, +}); + export const mockFeatureRedisClient = Object.assign(new Feature(), { name: KnownFeatures.RedisClient, flag: true, diff --git a/redisinsight/api/src/__mocks__/redis-enterprise.ts b/redisinsight/api/src/__mocks__/redis-enterprise.ts index 58488cb87b..e4138be503 100644 --- a/redisinsight/api/src/__mocks__/redis-enterprise.ts +++ b/redisinsight/api/src/__mocks__/redis-enterprise.ts @@ -1,5 +1,6 @@ import { RedisEnterpriseDatabase } from 'src/modules/redis-enterprise/dto/cluster.dto'; import { RedisEnterpriseDatabaseStatus } from 'src/modules/redis-enterprise/models/redis-enterprise-database'; +import { AddRedisEnterpriseDatabasesDto } from 'src/modules/redis-enterprise/dto/redis-enterprise-cluster.dto'; export const mockRedisEnterpriseDatabaseDto: RedisEnterpriseDatabase = { uid: 1, @@ -14,7 +15,19 @@ export const mockRedisEnterpriseDatabaseDto: RedisEnterpriseDatabase = { password: null, }; +export const mockAddRedisEnterpriseDatabasesDto = Object.assign(new AddRedisEnterpriseDatabasesDto(), { + host: 'localhost', + port: 9443, + username: 'admin', + password: 'password', + uids: [1], +}); + export const mockRedisEnterpriseAnalytics = jest.fn(() => ({ sendGetREClusterDbsSucceedEvent: jest.fn(), sendGetREClusterDbsFailedEvent: jest.fn(), })); + +export const mockRedisEnterpriseService = jest.fn(() => ({ + addRedisEnterpriseDatabases: jest.fn().mockResolvedValue([]), +})); diff --git a/redisinsight/api/src/__mocks__/redis-sentinel.ts b/redisinsight/api/src/__mocks__/redis-sentinel.ts index 4707139087..d2060d1e41 100644 --- a/redisinsight/api/src/__mocks__/redis-sentinel.ts +++ b/redisinsight/api/src/__mocks__/redis-sentinel.ts @@ -1,5 +1,6 @@ import { SentinelMaster, SentinelMasterStatus } from 'src/modules/redis-sentinel/models/sentinel-master'; import { Endpoint } from 'src/common/models'; +import { CreateSentinelDatabasesDto } from 'src/modules/redis-sentinel/dto/create.sentinel.databases.dto'; export const mockOtherSentinelsReply = [[ 'ip', @@ -27,7 +28,22 @@ export const mockSentinelMasterDto: SentinelMaster = { nodes: [mockOtherSentinelEndpoint], }; +export const mockCreateSentinelDatabasesDto = Object.assign(new CreateSentinelDatabasesDto(), { + ...mockOtherSentinelEndpoint, + masters: [ + { + name: mockSentinelMasterDto.name, + alias: mockSentinelMasterDto.name, + }, + ], +}); + export const mockRedisSentinelAnalytics = jest.fn(() => ({ sendGetSentinelMastersSucceedEvent: jest.fn(), sendGetSentinelMastersFailedEvent: jest.fn(), })); + +export const mockRedisSentinelService = jest.fn(() => ({ + getSentinelMasters: jest.fn().mockResolvedValue([mockSentinelMasterDto]), + createSentinelDatabases: jest.fn().mockResolvedValue([]), +})); diff --git a/redisinsight/api/src/common/decorators/database-management.decorator.ts b/redisinsight/api/src/common/decorators/database-management.decorator.ts new file mode 100644 index 0000000000..3995451d20 --- /dev/null +++ b/redisinsight/api/src/common/decorators/database-management.decorator.ts @@ -0,0 +1,8 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { DatabaseManagementGuard } from 'src/common/guards/database-management.guard'; + +export function DatabaseManagement() { + return applyDecorators( + UseGuards(new DatabaseManagementGuard()), + ); +} diff --git a/redisinsight/api/src/common/decorators/index.ts b/redisinsight/api/src/common/decorators/index.ts index b9a31d41aa..54f0d2f81a 100644 --- a/redisinsight/api/src/common/decorators/index.ts +++ b/redisinsight/api/src/common/decorators/index.ts @@ -8,3 +8,4 @@ export * from './object-as-map.decorator'; export * from './is-multi-number.decorator'; export * from './is-bigger-than.decorator'; export * from './is-github-link.decorator'; +export * from './database-management.decorator'; diff --git a/redisinsight/api/src/common/guards/database-management.guard.spec.ts b/redisinsight/api/src/common/guards/database-management.guard.spec.ts new file mode 100644 index 0000000000..10f946a623 --- /dev/null +++ b/redisinsight/api/src/common/guards/database-management.guard.spec.ts @@ -0,0 +1,36 @@ +import { when } from 'jest-when'; +import { DatabaseManagementGuard } from 'src/common/guards/database-management.guard'; +import { ForbiddenException } from '@nestjs/common'; +import config, { Config } from 'src/utils/config'; + +const mockServerConfig = config.get('server') as Config['server']; + +jest.mock('src/utils/config', jest.fn( + () => jest.requireActual('src/utils/config') as object, +)); + +describe('DatabaseManagementGuard', () => { + let guard: DatabaseManagementGuard; + let configGetSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + configGetSpy = jest.spyOn(config, 'get'); + + when(configGetSpy).calledWith('server').mockReturnValue(mockServerConfig); + + guard = new DatabaseManagementGuard(); + }); + + it('should return true', () => { + mockServerConfig.databaseManagement = true; + + expect(guard.canActivate()).toEqual(true); + }); + + it('should throw an error when database management is disabled', () => { + mockServerConfig.databaseManagement = false; + + expect(guard.canActivate).toThrowError(new ForbiddenException('Database connection management is disabled.')); + }); +}); diff --git a/redisinsight/api/src/common/guards/database-management.guard.ts b/redisinsight/api/src/common/guards/database-management.guard.ts new file mode 100644 index 0000000000..61a47ce736 --- /dev/null +++ b/redisinsight/api/src/common/guards/database-management.guard.ts @@ -0,0 +1,16 @@ +import { CanActivate, ForbiddenException, Injectable } from '@nestjs/common'; +import config, { Config } from 'src/utils/config'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +const SERVER_CONFIG = config.get('server') as Config['server']; + +@Injectable() +export class DatabaseManagementGuard implements CanActivate { + canActivate(): boolean { + if (!SERVER_CONFIG.databaseManagement) { + throw new ForbiddenException(ERROR_MESSAGES.DATABASE_MANAGEMENT_IS_DISABLED); + } + + return true; + } +} diff --git a/redisinsight/api/src/constants/custom-error-codes.ts b/redisinsight/api/src/constants/custom-error-codes.ts index 58bc56e0a5..b8a85c3e27 100644 --- a/redisinsight/api/src/constants/custom-error-codes.ts +++ b/redisinsight/api/src/constants/custom-error-codes.ts @@ -35,6 +35,7 @@ export enum CustomErrorCodes { CloudTaskNotFound = 11_112, CloudJobNotFound = 11_113, CloudSubscriptionAlreadyExistsFree = 11_114, + CloudDatabaseImportForbidden = 11_115, // General database errors [11200, 11299] DatabaseAlreadyExists = 11_200, diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index a20c286eb9..dd310656c4 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -38,6 +38,7 @@ export default { INCORRECT_CERTIFICATES: (url) => `Could not connect to ${url}, please check the CA or Client certificate.`, INCORRECT_CREDENTIALS: (url) => `Could not connect to ${url}, please check the Username or Password.`, + DATABASE_MANAGEMENT_IS_DISABLED: 'Database connection management is disabled.', CA_CERT_EXIST: 'This ca certificate name is already in use.', INVALID_CA_BODY: 'Invalid CA body', INVALID_SSH_PRIVATE_KEY_BODY: 'Invalid SSH private key body', @@ -111,6 +112,7 @@ export default { CLOUD_DATABASE_IN_FAILED_STATE: 'Cloud database is in the failed state', CLOUD_DATABASE_IN_UNEXPECTED_STATE: 'Cloud database is in unexpected state', CLOUD_DATABASE_ALREADY_EXISTS_FREE: 'Free trial database already exists', + CLOUD_DATABASE_IMPORT_FORBIDDEN: 'Adding your Redis Cloud database to Redis Insight is disabled due to a setting restricting database connection management.', CLOUD_PLAN_NOT_FOUND_FREE: 'Unable to find free cloud plan', CLOUD_SUBSCRIPTION_ALREADY_EXISTS_FREE: 'Free subscription already exists', COMMON_DEFAULT_IMPORT_ERROR: 'Unable to import default data', diff --git a/redisinsight/api/src/modules/browser/keys/keys.service.ts b/redisinsight/api/src/modules/browser/keys/keys.service.ts index 19aeb5f50d..ee4f8e8978 100644 --- a/redisinsight/api/src/modules/browser/keys/keys.service.ts +++ b/redisinsight/api/src/modules/browser/keys/keys.service.ts @@ -313,4 +313,4 @@ export class KeysService { throw catchAclError(error); } } -} \ No newline at end of file +} diff --git a/redisinsight/api/src/modules/cloud/job/cloud-job.factory.ts b/redisinsight/api/src/modules/cloud/job/cloud-job.factory.ts index f8f8208d4a..4f5209c4a5 100644 --- a/redisinsight/api/src/modules/cloud/job/cloud-job.factory.ts +++ b/redisinsight/api/src/modules/cloud/job/cloud-job.factory.ts @@ -19,6 +19,7 @@ import { CloudCapiKeyService } from 'src/modules/cloud/capi-key/cloud-capi-key.s import { CloudSubscriptionApiService } from 'src/modules/cloud/subscription/cloud-subscription.api.service'; import { BulkImportService } from 'src/modules/bulk-actions/bulk-import.service'; import { DatabaseInfoService } from 'src/modules/database/database-info.service'; +import { FeatureService } from 'src/modules/feature/feature.service'; @Injectable() export class CloudJobFactory { @@ -32,6 +33,7 @@ export class CloudJobFactory { private readonly bulkImportService: BulkImportService, private readonly cloudCapiKeyService: CloudCapiKeyService, private readonly cloudSubscriptionApiService: CloudSubscriptionApiService, + private readonly featureService: FeatureService, ) {} async create( @@ -62,6 +64,7 @@ export class CloudJobFactory { bulkImportService: this.bulkImportService, cloudCapiKeyService: this.cloudCapiKeyService, cloudSubscriptionApiService: this.cloudSubscriptionApiService, + featureService: this.featureService, }, ); case CloudJobName.CreateFreeDatabase: @@ -80,6 +83,7 @@ export class CloudJobFactory { databaseInfoService: this.databaseInfoService, bulkImportService: this.bulkImportService, cloudCapiKeyService: this.cloudCapiKeyService, + featureService: this.featureService, }, ); case CloudJobName.ImportFreeDatabase: @@ -96,6 +100,7 @@ export class CloudJobFactory { cloudDatabaseAnalytics: this.cloudDatabaseAnalytics, databaseService: this.databaseService, cloudCapiKeyService: this.cloudCapiKeyService, + featureService: this.featureService, }, ); default: diff --git a/redisinsight/api/src/modules/cloud/job/exceptions/cloud-database-import-forbidden.exception.ts b/redisinsight/api/src/modules/cloud/job/exceptions/cloud-database-import-forbidden.exception.ts new file mode 100644 index 0000000000..cea9d3ed11 --- /dev/null +++ b/redisinsight/api/src/modules/cloud/job/exceptions/cloud-database-import-forbidden.exception.ts @@ -0,0 +1,19 @@ +import { HttpException, HttpExceptionOptions, HttpStatus } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomErrorCodes } from 'src/constants'; + +export class CloudDatabaseImportForbiddenException extends HttpException { + constructor( + message = ERROR_MESSAGES.CLOUD_DATABASE_IMPORT_FORBIDDEN, + options?: HttpExceptionOptions, + ) { + const response = { + message, + statusCode: HttpStatus.FORBIDDEN, + error: 'CloudDatabaseImportForbidden', + errorCode: CustomErrorCodes.CloudDatabaseImportForbidden, + }; + + super(response, response.statusCode, options); + } +} diff --git a/redisinsight/api/src/modules/cloud/job/exceptions/index.ts b/redisinsight/api/src/modules/cloud/job/exceptions/index.ts index 709a6bb68b..74a05861ba 100644 --- a/redisinsight/api/src/modules/cloud/job/exceptions/index.ts +++ b/redisinsight/api/src/modules/cloud/job/exceptions/index.ts @@ -1,4 +1,5 @@ export * from './cloud-database-already-exists-free.exception'; +export * from './cloud-database-import-forbidden.exception'; export * from './cloud-database-in-failed-state.exception'; export * from './cloud-database-in-unexpected-state.exception'; export * from './cloud-job.error.handler'; diff --git a/redisinsight/api/src/modules/cloud/job/jobs/create-free-database.cloud-job.ts b/redisinsight/api/src/modules/cloud/job/jobs/create-free-database.cloud-job.ts index 90f8e8f224..93e0040610 100644 --- a/redisinsight/api/src/modules/cloud/job/jobs/create-free-database.cloud-job.ts +++ b/redisinsight/api/src/modules/cloud/job/jobs/create-free-database.cloud-job.ts @@ -10,6 +10,7 @@ import { WaitForActiveDatabaseCloudJob } from 'src/modules/cloud/job/jobs/wait-f import { CloudJobName } from 'src/modules/cloud/job/constants'; import { CloudJobStatus, CloudJobStep } from 'src/modules/cloud/job/models'; import { + CloudDatabaseImportForbiddenException, CloudJobUnexpectedErrorException, CloudTaskNoResourceIdException, } from 'src/modules/cloud/job/exceptions'; @@ -22,6 +23,8 @@ import { CloudCapiKeyService } from 'src/modules/cloud/capi-key/cloud-capi-key.s import { BulkImportService } from 'src/modules/bulk-actions/bulk-import.service'; import { ClientContext, SessionMetadata } from 'src/common/models'; import { DatabaseInfoService } from 'src/modules/database/database-info.service'; +import { FeatureService } from 'src/modules/feature/feature.service'; +import { KnownFeatures } from 'src/modules/feature/constants'; const cloudConfig = config.get('cloud'); @@ -42,6 +45,7 @@ export class CreateFreeDatabaseCloudJob extends CloudJob { databaseInfoService: DatabaseInfoService, bulkImportService: BulkImportService, cloudCapiKeyService: CloudCapiKeyService, + featureService: FeatureService, }, ) { super(options); @@ -113,6 +117,15 @@ export class CreateFreeDatabaseCloudJob extends CloudJob { this.checkSignal(); + const isDatabaseManagementEnabled = await this.dependencies.featureService.isFeatureEnabled( + sessionMetadata, + KnownFeatures.DatabaseManagement, + ); + + if (!isDatabaseManagementEnabled) { + throw new CloudDatabaseImportForbiddenException(); + } + const { publicEndpoint, name, diff --git a/redisinsight/api/src/modules/cloud/job/jobs/create-free-subscription-and-database.cloud-job.ts b/redisinsight/api/src/modules/cloud/job/jobs/create-free-subscription-and-database.cloud-job.ts index 7b29d3b92d..39c748926d 100644 --- a/redisinsight/api/src/modules/cloud/job/jobs/create-free-subscription-and-database.cloud-job.ts +++ b/redisinsight/api/src/modules/cloud/job/jobs/create-free-subscription-and-database.cloud-job.ts @@ -14,6 +14,7 @@ import { CloudSubscription } from 'src/modules/cloud/subscription/models'; import { DatabaseInfoService } from 'src/modules/database/database-info.service'; import { BulkImportService } from 'src/modules/bulk-actions/bulk-import.service'; import { SessionMetadata } from 'src/common/models'; +import { FeatureService } from 'src/modules/feature/feature.service'; import { CloudSubscriptionApiService } from '../../subscription/cloud-subscription.api.service'; import { CloudSubscriptionPlanResponse } from '../../subscription/dto'; @@ -38,6 +39,7 @@ export class CreateFreeSubscriptionAndDatabaseCloudJob extends CloudJob { bulkImportService: BulkImportService, cloudCapiKeyService: CloudCapiKeyService, cloudSubscriptionApiService: CloudSubscriptionApiService, + featureService: FeatureService, }, ) { super(options); diff --git a/redisinsight/api/src/modules/cloud/job/jobs/import-free-database.cloud-job.ts b/redisinsight/api/src/modules/cloud/job/jobs/import-free-database.cloud-job.ts index 1a492457d8..e47d9015cd 100644 --- a/redisinsight/api/src/modules/cloud/job/jobs/import-free-database.cloud-job.ts +++ b/redisinsight/api/src/modules/cloud/job/jobs/import-free-database.cloud-job.ts @@ -14,6 +14,9 @@ import config from 'src/utils/config'; import { CloudDatabaseAnalytics } from 'src/modules/cloud/database/cloud-database.analytics'; import { CloudCapiKeyService } from 'src/modules/cloud/capi-key/cloud-capi-key.service'; import { SessionMetadata } from 'src/common/models'; +import { KnownFeatures } from 'src/modules/feature/constants'; +import { FeatureService } from 'src/modules/feature/feature.service'; +import { CloudDatabaseImportForbiddenException } from 'src/modules/cloud/job/exceptions'; const cloudConfig = config.get('cloud'); @@ -35,6 +38,7 @@ export class ImportFreeDatabaseCloudJob extends CloudJob { cloudDatabaseAnalytics: CloudDatabaseAnalytics, databaseService: DatabaseService, cloudCapiKeyService: CloudCapiKeyService, + featureService: FeatureService, }, ) { super(options); @@ -66,6 +70,15 @@ export class ImportFreeDatabaseCloudJob extends CloudJob { this.checkSignal(); + const isDatabaseManagementEnabled = await this.dependencies.featureService.isFeatureEnabled( + sessionMetadata, + KnownFeatures.DatabaseManagement, + ); + + if (!isDatabaseManagementEnabled) { + throw new CloudDatabaseImportForbiddenException(); + } + const { publicEndpoint, name, diff --git a/redisinsight/api/src/modules/database-import/database-import.controller.spec.ts b/redisinsight/api/src/modules/database-import/database-import.controller.spec.ts new file mode 100644 index 0000000000..11860c091b --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.controller.spec.ts @@ -0,0 +1,88 @@ +import { when } from 'jest-when'; +import * as request from 'supertest'; +import { Test } from '@nestjs/testing'; +import { ForbiddenException, INestApplication, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { + mockDatabase, + mockDatabaseImportService, + mockSessionService, +} from 'src/__mocks__'; +import { SingleUserAuthMiddleware } from 'src/common/middlewares/single-user-auth.middleware'; +import { SessionService } from 'src/modules/session/session.service'; +import config, { Config } from 'src/utils/config'; +import { DatabaseImportController } from 'src/modules/database-import/database-import.controller'; +import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; + +const mockServerConfig = config.get('server') as Config['server']; + +jest.mock('src/utils/config', jest.fn( + () => jest.requireActual('src/utils/config') as object, +)); + +@Module({ + controllers: [DatabaseImportController], + providers: [ + { + provide: DatabaseImportService, + useFactory: mockDatabaseImportService, + }, + { + provide: SessionService, + useFactory: mockSessionService, + }, + ], +}) +class TestModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(SingleUserAuthMiddleware) + .forRoutes('*'); + } +} + +describe('DatabaseImportController', () => { + let app: INestApplication; + let configGetSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + + configGetSpy = jest.spyOn(config, 'get'); + + when(configGetSpy).calledWith('server').mockReturnValue(mockServerConfig); + + const moduleRef = await Test.createTestingModule({ + imports: [TestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /databases/import', () => { + it('should import databases', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .post('/databases/import') + .send([mockDatabase]) + .expect(200); + }); + + it('should fail to import databases when database management disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .post('/databases/import') + .send([mockDatabase]) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/database-import/database-import.controller.ts b/redisinsight/api/src/modules/database-import/database-import.controller.ts index 20e3979699..e4252f9517 100644 --- a/redisinsight/api/src/modules/database-import/database-import.controller.ts +++ b/redisinsight/api/src/modules/database-import/database-import.controller.ts @@ -9,7 +9,7 @@ import { import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; import { FileInterceptor } from '@nestjs/platform-express'; import { DatabaseImportResponse } from 'src/modules/database-import/dto/database-import.response'; -import { RequestSessionMetadata } from 'src/common/decorators'; +import { DatabaseManagement, RequestSessionMetadata } from 'src/common/decorators'; import { SessionMetadata } from 'src/common/models'; @UsePipes(new ValidationPipe({ transform: true })) @@ -35,6 +35,7 @@ export class DatabaseImportController { @HttpCode(200) @UseInterceptors(FileInterceptor('file')) @ApiResponse({ type: DatabaseImportResponse }) + @DatabaseManagement() async import( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @UploadedFile() file: any, diff --git a/redisinsight/api/src/modules/database/database.controller.spec.ts b/redisinsight/api/src/modules/database/database.controller.spec.ts new file mode 100644 index 0000000000..cf5828b5c3 --- /dev/null +++ b/redisinsight/api/src/modules/database/database.controller.spec.ts @@ -0,0 +1,230 @@ +import { when } from 'jest-when'; +import * as request from 'supertest'; +import { Test } from '@nestjs/testing'; +import { ForbiddenException, INestApplication, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { + mockCreateDatabaseDto, mockDatabase, + mockDatabaseConnectionService, + mockDatabaseService, + mockSessionService, +} from 'src/__mocks__'; +import { SingleUserAuthMiddleware } from 'src/common/middlewares/single-user-auth.middleware'; +import { SessionService } from 'src/modules/session/session.service'; +import config, { Config } from 'src/utils/config'; +import { DatabaseController } from 'src/modules/database/database.controller'; +import { DatabaseService } from 'src/modules/database/database.service'; +import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; + +const mockServerConfig = config.get('server') as Config['server']; + +jest.mock('src/utils/config', jest.fn( + () => jest.requireActual('src/utils/config') as object, +)); + +@Module({ + controllers: [DatabaseController], + providers: [ + { + provide: DatabaseService, + useFactory: mockDatabaseService, + }, + { + provide: DatabaseConnectionService, + useFactory: mockDatabaseConnectionService, + }, + { + provide: SessionService, + useFactory: mockSessionService, + }, + ], +}) +class TestModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(SingleUserAuthMiddleware) + .forRoutes('*'); + } +} + +describe('DatabaseController', () => { + let app: INestApplication; + let configGetSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + + configGetSpy = jest.spyOn(config, 'get'); + + when(configGetSpy).calledWith('server').mockReturnValue(mockServerConfig); + + const moduleRef = await Test.createTestingModule({ + imports: [TestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /databases', () => { + it('should create database', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .post('/databases') + .send(mockCreateDatabaseDto) + .expect(201); + }); + + it('should fail to create database when database management disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .post('/databases') + .send(mockCreateDatabaseDto) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); + + describe('PATCH /databases/:id', () => { + it('should update database', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .patch(`/databases/${mockDatabase.id}`) + .send(mockCreateDatabaseDto) + .expect(200); + }); + + it('should fail to update database when database management is disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .patch(`/databases/${mockDatabase.id}`) + .send(mockCreateDatabaseDto) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); + + describe('POST /databases/clone/:id', () => { + it('should clone database', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .post(`/databases/clone/${mockDatabase.id}`) + .send(mockCreateDatabaseDto) + .expect(200); + }); + + it('should fail to clone database when database management is disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .post(`/databases/clone/${mockDatabase.id}`) + .send(mockCreateDatabaseDto) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); + + describe('POST /databases/test', () => { + it('should test database connection', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .post('/databases/test') + .send(mockCreateDatabaseDto) + .expect(200); + }); + + it('should fail to test database connection when database management disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .post('/databases/test') + .send(mockCreateDatabaseDto) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); + + describe('DELETE /databases/:id', () => { + it('should delete database', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .delete(`/databases/${mockDatabase.id}`) + .expect(200); + }); + + it('should fail to delete database when database management is disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .delete(`/databases/${mockDatabase.id}`) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); + + describe('DELETE /databases', () => { + it('should delete databases', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .delete('/databases') + .send({ ids: [mockDatabase.id] }) + .expect(200); + }); + + it('should fail to delete databases when database management is disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .delete('/databases') + .send({ ids: [mockDatabase.id] }) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); + + describe('POST /export', () => { + it('should export databases', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .delete('/databases/export') + .send({ ids: [mockDatabase.id] }) + .expect(200); + }); + + it('should fail to export databases when database management is disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .delete('/databases/export') + .send({ ids: [mockDatabase.id] }) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/database/database.controller.ts b/redisinsight/api/src/modules/database/database.controller.ts index a9945f1a76..a250a4f81a 100644 --- a/redisinsight/api/src/modules/database/database.controller.ts +++ b/redisinsight/api/src/modules/database/database.controller.ts @@ -23,7 +23,7 @@ import { UpdateDatabaseDto } from 'src/modules/database/dto/update.database.dto' import { BuildType } from 'src/modules/server/models/server'; import { DeleteDatabasesDto } from 'src/modules/database/dto/delete.databases.dto'; import { DeleteDatabasesResponse } from 'src/modules/database/dto/delete.databases.response'; -import { ClientMetadataParam, RequestSessionMetadata } from 'src/common/decorators'; +import { ClientMetadataParam, RequestSessionMetadata, DatabaseManagement } from 'src/common/decorators'; import { ClientMetadata, SessionMetadata } from 'src/common/models'; import { ExportDatabasesDto } from 'src/modules/database/dto/export.databases.dto'; import { ExportDatabase } from 'src/modules/database/models/export-database'; @@ -98,6 +98,7 @@ export class DatabaseController { forbidNonWhitelisted: true, }), ) + @DatabaseManagement() async create( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Body() dto: CreateDatabaseDto, @@ -126,6 +127,7 @@ export class DatabaseController { forbidNonWhitelisted: true, }), ) + @DatabaseManagement() async update( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Param('id') id: string, @@ -155,6 +157,7 @@ export class DatabaseController { forbidNonWhitelisted: true, }), ) + @DatabaseManagement() async clone( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Param('id') id: string, @@ -180,6 +183,7 @@ export class DatabaseController { whitelist: true, }), ) + @DatabaseManagement() async testConnection( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Body() dto: CreateDatabaseDto, @@ -219,6 +223,7 @@ export class DatabaseController { description: 'Delete database instance by id', excludeFor: [BuildType.RedisStack], }) + @DatabaseManagement() async deleteDatabaseInstance( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Param('id') id: string, @@ -240,6 +245,7 @@ export class DatabaseController { ], }) @UsePipes(new ValidationPipe({ transform: true })) + @DatabaseManagement() async bulkDeleteDatabaseInstance( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Body() dto: DeleteDatabasesDto, @@ -282,6 +288,7 @@ export class DatabaseController { ], }) @UsePipes(new ValidationPipe({ transform: true })) + @DatabaseManagement() async exportConnections( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Body() dto: ExportDatabasesDto, diff --git a/redisinsight/api/src/modules/feature/constants/index.ts b/redisinsight/api/src/modules/feature/constants/index.ts index df8e858dc7..ba9e5ad2b9 100644 --- a/redisinsight/api/src/modules/feature/constants/index.ts +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -30,6 +30,7 @@ export enum KnownFeatures { Rdi = 'redisDataIntegration', HashFieldExpiration = 'hashFieldExpiration', EnhancedCloudUI = 'enhancedCloudUI', + DatabaseManagement = 'databaseManagement', } export interface IFeatureFlag { diff --git a/redisinsight/api/src/modules/feature/constants/known-features.ts b/redisinsight/api/src/modules/feature/constants/known-features.ts index 4f090140c7..70b680f731 100644 --- a/redisinsight/api/src/modules/feature/constants/known-features.ts +++ b/redisinsight/api/src/modules/feature/constants/known-features.ts @@ -1,5 +1,8 @@ import { FeatureStorage, IFeatureFlag, KnownFeatures } from 'src/modules/feature/constants/index'; import { CloudSsoFeatureFlag } from 'src/modules/cloud/cloud-sso.feature.flag'; +import config, { Config } from 'src/utils/config'; + +const SERVER_CONFIG = config.get('server') as Config['server']; export const knownFeatures: Record = { [KnownFeatures.InsightsRecommendations]: { @@ -43,4 +46,12 @@ export const knownFeatures: Record = { name: KnownFeatures.EnhancedCloudUI, storage: FeatureStorage.Database, }, + [KnownFeatures.DatabaseManagement]: { + name: KnownFeatures.DatabaseManagement, + storage: FeatureStorage.Custom, + factory: () => ({ + name: KnownFeatures.DatabaseManagement, + flag: SERVER_CONFIG.databaseManagement, + }), + }, }; diff --git a/redisinsight/api/src/modules/feature/local.feature.service.spec.ts b/redisinsight/api/src/modules/feature/local.feature.service.spec.ts index 84e2916d6b..117f630382 100644 --- a/redisinsight/api/src/modules/feature/local.feature.service.spec.ts +++ b/redisinsight/api/src/modules/feature/local.feature.service.spec.ts @@ -3,6 +3,7 @@ import axios from 'axios'; import { mockConstantsProvider, mockControlGroup, mockControlNumber, mockFeature, mockFeatureAnalytics, mockFeatureFlagProvider, mockFeatureRepository, + mockFeatureDatabaseManagement, mockFeaturesConfig, mockFeaturesConfigJson, mockFeaturesConfigRepository, mockFeaturesConfigService, mockFeatureSso, mockSessionMetadata, @@ -91,6 +92,15 @@ describe('FeatureService', () => { expect(await service.getByName(mockSessionMetadata, KnownFeatures.InsightsRecommendations)).toEqual(mockFeature); expect(featureRepository.get).toHaveBeenCalledWith(mockSessionMetadata, KnownFeatures.InsightsRecommendations); }); + it('should return feature with "custom" storage', async () => { + expect(await service.getByName(mockSessionMetadata, KnownFeatures.DatabaseManagement)) + .toEqual(mockFeatureDatabaseManagement); + expect(featureRepository.get).not.toHaveBeenCalledWith(); + }); + it('should return null for unsupported storage type (undefined in current test)', async () => { + expect(await service.getByName(mockSessionMetadata, 'unknown feature')).toEqual(null); + expect(featureRepository.get).not.toHaveBeenCalledWith(); + }); it('should return null when feature doesn\'t exists', async () => { featureRepository.get.mockResolvedValueOnce(null); expect(await service.getByName(mockSessionMetadata, KnownFeatures.InsightsRecommendations)).toEqual(null); @@ -110,6 +120,14 @@ describe('FeatureService', () => { repository.get.mockResolvedValue({ flag: false }); expect(await service.isFeatureEnabled(mockSessionMetadata, KnownFeatures.InsightsRecommendations)).toEqual(false); }); + it('should return true for custom storage', async () => { + expect(await service.isFeatureEnabled(mockSessionMetadata, KnownFeatures.DatabaseManagement)).toEqual(true); + expect(featureRepository.get).not.toHaveBeenCalledWith(); + }); + it('should return false for unsupported storage type (undefined in current test)', async () => { + expect(await service.isFeatureEnabled(mockSessionMetadata, 'unknown feature')).toEqual(false); + expect(featureRepository.get).not.toHaveBeenCalledWith(); + }); it('should return false in case of an error', async () => { repository.get.mockRejectedValueOnce(new Error('Unable to fetch flag from db')); expect(await service.isFeatureEnabled(mockSessionMetadata, KnownFeatures.InsightsRecommendations)).toEqual(false); @@ -125,6 +143,7 @@ describe('FeatureService', () => { features: { [KnownFeatures.InsightsRecommendations]: mockFeature, [KnownFeatures.CloudSso]: mockFeatureSso, + [KnownFeatures.DatabaseManagement]: mockFeatureDatabaseManagement, }, }); }); @@ -152,6 +171,7 @@ describe('FeatureService', () => { features: { [KnownFeatures.InsightsRecommendations]: mockFeature, [KnownFeatures.CloudSso]: mockFeatureSso, + [KnownFeatures.DatabaseManagement]: mockFeatureDatabaseManagement, }, force: { [KnownFeatures.CloudSso]: false, diff --git a/redisinsight/api/src/modules/feature/local.feature.service.ts b/redisinsight/api/src/modules/feature/local.feature.service.ts index f27a7878b8..f9c81b47b6 100644 --- a/redisinsight/api/src/modules/feature/local.feature.service.ts +++ b/redisinsight/api/src/modules/feature/local.feature.service.ts @@ -38,7 +38,14 @@ export class LocalFeatureService extends FeatureService { */ async getByName(sessionMetadata: SessionMetadata, name: string): Promise { try { - return await this.repository.get(sessionMetadata, name); + switch (knownFeatures[name]?.storage) { + case FeatureStorage.Database: + return await this.repository.get(sessionMetadata, name); + case FeatureStorage.Custom: + return knownFeatures[name].factory?.(); + default: + return null; + } } catch (e) { return null; } @@ -49,10 +56,14 @@ export class LocalFeatureService extends FeatureService { */ async isFeatureEnabled(sessionMetadata: SessionMetadata, name: string): Promise { try { - // todo: add non-database features if needed - const model = await this.repository.get(sessionMetadata, name); - - return model?.flag === true; + switch (knownFeatures[name]?.storage) { + case FeatureStorage.Database: + return (await this.repository.get(sessionMetadata, name))?.flag === true; + case FeatureStorage.Custom: + return (knownFeatures[name].factory?.())?.flag === true; + default: + return false; + } } catch (e) { return false; } diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.controller.spec.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.controller.spec.ts new file mode 100644 index 0000000000..969b8ac5fb --- /dev/null +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.controller.spec.ts @@ -0,0 +1,88 @@ +import { when } from 'jest-when'; +import * as request from 'supertest'; +import { Test } from '@nestjs/testing'; +import { ForbiddenException, INestApplication, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { + mockAddRedisEnterpriseDatabasesDto, + mockRedisEnterpriseService, + mockSessionService, +} from 'src/__mocks__'; +import { SingleUserAuthMiddleware } from 'src/common/middlewares/single-user-auth.middleware'; +import { SessionService } from 'src/modules/session/session.service'; +import config, { Config } from 'src/utils/config'; +import { RedisEnterpriseController } from 'src/modules/redis-enterprise/redis-enterprise.controller'; +import { RedisEnterpriseService } from 'src/modules/redis-enterprise/redis-enterprise.service'; + +const mockServerConfig = config.get('server') as Config['server']; + +jest.mock('src/utils/config', jest.fn( + () => jest.requireActual('src/utils/config') as object, +)); + +@Module({ + controllers: [RedisEnterpriseController], + providers: [ + { + provide: RedisEnterpriseService, + useFactory: mockRedisEnterpriseService, + }, + { + provide: SessionService, + useFactory: mockSessionService, + }, + ], +}) +class TestModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(SingleUserAuthMiddleware) + .forRoutes('*'); + } +} + +describe('RedisEnterpriseController', () => { + let app: INestApplication; + let configGetSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + + configGetSpy = jest.spyOn(config, 'get'); + + when(configGetSpy).calledWith('server').mockReturnValue(mockServerConfig); + + const moduleRef = await Test.createTestingModule({ + imports: [TestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /redis-enterprise/cluster/databases', () => { + it('should succeed when database management enabled', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .post('/redis-enterprise/cluster/databases') + .send(mockAddRedisEnterpriseDatabasesDto) + .expect(200); + }); + + it('should fail when database management disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .post('/redis-enterprise/cluster/databases') + .send(mockAddRedisEnterpriseDatabasesDto) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.controller.ts b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.controller.ts index 0f72743c64..fd18557b27 100644 --- a/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.controller.ts +++ b/redisinsight/api/src/modules/redis-enterprise/redis-enterprise.controller.ts @@ -18,7 +18,7 @@ import { import { Response } from 'express'; import { ActionStatus, SessionMetadata } from 'src/common/models'; import { BuildType } from 'src/modules/server/models/server'; -import { RequestSessionMetadata } from 'src/common/decorators'; +import { DatabaseManagement, RequestSessionMetadata } from 'src/common/decorators'; import { ClusterConnectionDetailsDto, RedisEnterpriseDatabase } from 'src/modules/redis-enterprise/dto/cluster.dto'; @ApiTags('Redis Enterprise Cluster') @@ -65,6 +65,7 @@ export class RedisEnterpriseController { ], }) @UsePipes(new ValidationPipe({ transform: true })) + @DatabaseManagement() async addRedisEnterpriseDatabases( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Body() dto: AddRedisEnterpriseDatabasesDto, diff --git a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.controller.spec.ts b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.controller.spec.ts new file mode 100644 index 0000000000..876b93dc8f --- /dev/null +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.controller.spec.ts @@ -0,0 +1,84 @@ +import { when } from 'jest-when'; +import * as request from 'supertest'; +import { Test } from '@nestjs/testing'; +import { ForbiddenException, INestApplication, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { RedisSentinelService } from 'src/modules/redis-sentinel/redis-sentinel.service'; +import { mockCreateSentinelDatabasesDto, mockRedisSentinelService, mockSessionService } from 'src/__mocks__'; +import { RedisSentinelController } from 'src/modules/redis-sentinel/redis-sentinel.controller'; +import { SingleUserAuthMiddleware } from 'src/common/middlewares/single-user-auth.middleware'; +import { SessionService } from 'src/modules/session/session.service'; +import config, { Config } from 'src/utils/config'; + +const mockServerConfig = config.get('server') as Config['server']; + +jest.mock('src/utils/config', jest.fn( + () => jest.requireActual('src/utils/config') as object, +)); + +@Module({ + controllers: [RedisSentinelController], + providers: [ + { + provide: RedisSentinelService, + useFactory: mockRedisSentinelService, + }, + { + provide: SessionService, + useFactory: mockSessionService, + }, + ], +}) +class TestModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(SingleUserAuthMiddleware) + .forRoutes('*'); + } +} + +describe('RedisSentinelController', () => { + let app: INestApplication; + let configGetSpy: jest.SpyInstance; + + beforeEach(async () => { + jest.clearAllMocks(); + + configGetSpy = jest.spyOn(config, 'get'); + + when(configGetSpy).calledWith('server').mockReturnValue(mockServerConfig); + + const moduleRef = await Test.createTestingModule({ + imports: [TestModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /redis-sentinel/databases', () => { + it('should succeed when database management enabled', async () => { + mockServerConfig.databaseManagement = true; + + return request(app.getHttpServer()) + .post('/redis-sentinel/databases') + .send(mockCreateSentinelDatabasesDto) + .expect(200); + }); + + it('should fail when database management disabled', async () => { + mockServerConfig.databaseManagement = false; + + return request(app.getHttpServer()) + .post('/redis-sentinel/databases') + .send(mockCreateSentinelDatabasesDto) + .expect(403) + .expect( + (new ForbiddenException('Database connection management is disabled.')).getResponse(), + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.controller.ts b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.controller.ts index d040deeb43..6498f92595 100644 --- a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.controller.ts +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.controller.ts @@ -19,7 +19,7 @@ import { RedisSentinelService } from 'src/modules/redis-sentinel/redis-sentinel. import { CreateSentinelDatabasesDto } from 'src/modules/redis-sentinel/dto/create.sentinel.databases.dto'; import { CreateSentinelDatabaseResponse } from 'src/modules/redis-sentinel/dto/create.sentinel.database.response'; import { BuildType } from 'src/modules/server/models/server'; -import { RequestSessionMetadata } from 'src/common/decorators'; +import { DatabaseManagement, RequestSessionMetadata } from 'src/common/decorators'; @ApiTags('Redis OSS Sentinel') @Controller('redis-sentinel') @@ -71,6 +71,7 @@ export class RedisSentinelController { ], }) @UsePipes(new ValidationPipe({ transform: true })) + @DatabaseManagement() async addSentinelMasters( @RequestSessionMetadata() sessionMetadata: SessionMetadata, @Body() dto: CreateSentinelDatabasesDto, diff --git a/redisinsight/api/test/api/feature/GET-features.test.ts b/redisinsight/api/test/api/feature/GET-features.test.ts index b3b86655b7..7586ba238e 100644 --- a/redisinsight/api/test/api/feature/GET-features.test.ts +++ b/redisinsight/api/test/api/feature/GET-features.test.ts @@ -78,6 +78,10 @@ describe('GET /features', () => { flag: true, name: 'cloudSso', }, + databaseManagement: { + flag: true, + name: 'databaseManagement', + }, }, }, syncEndpoint); }, @@ -94,6 +98,10 @@ describe('GET /features', () => { flag: true, name: 'cloudSso', }, + databaseManagement: { + flag: true, + name: 'databaseManagement', + }, }); expect(body.controlNumber).to.eq(config.controlNumber); expect(body.controlGroup).to.be.a('string'); @@ -131,6 +139,10 @@ describe('GET /features', () => { flag: true, name: 'cloudSso', }, + databaseManagement: { + flag: true, + name: 'databaseManagement', + }, }, }, syncEndpoint); }, @@ -145,6 +157,10 @@ describe('GET /features', () => { flag: true, name: 'cloudSso', }, + databaseManagement: { + flag: true, + name: 'databaseManagement', + }, } } }, @@ -183,6 +199,10 @@ describe('GET /features', () => { flag: true, name: 'cloudSso', }, + databaseManagement: { + flag: true, + name: 'databaseManagement', + }, }, }, syncEndpoint); }, @@ -197,6 +217,10 @@ describe('GET /features', () => { flag: true, name: 'cloudSso', }, + databaseManagement: { + flag: true, + name: 'databaseManagement', + }, } } }, @@ -214,6 +238,10 @@ describe('GET /features', () => { flag: true, name: 'cloudSso', }, + databaseManagement: { + flag: true, + name: 'databaseManagement', + }, }, }).then(res).catch(rej); @@ -235,6 +263,10 @@ describe('GET /features', () => { flag: true, name: 'cloudSso', }, + databaseManagement: { + flag: true, + name: 'databaseManagement', + }, } } }, diff --git a/redisinsight/ui/src/components/item-list/ItemList.spec.tsx b/redisinsight/ui/src/components/item-list/ItemList.spec.tsx index e7b823a5af..a184fc18ef 100644 --- a/redisinsight/ui/src/components/item-list/ItemList.spec.tsx +++ b/redisinsight/ui/src/components/item-list/ItemList.spec.tsx @@ -141,4 +141,11 @@ describe('ItemList', () => { expect(screen.queryByText('Export')).not.toBeInTheDocument() }) + + it('should add hideSelectableCheckboxes class when isSelectable = false', async () => { + const { container } = render() + const div = container.querySelector('.itemList') + + expect(div).toHaveClass('hideSelectableCheckboxes') + }) }) diff --git a/redisinsight/ui/src/components/item-list/ItemList.tsx b/redisinsight/ui/src/components/item-list/ItemList.tsx index 539632e1d4..9bdf607068 100644 --- a/redisinsight/ui/src/components/item-list/ItemList.tsx +++ b/redisinsight/ui/src/components/item-list/ItemList.tsx @@ -28,7 +28,8 @@ export interface Props { loading: boolean data: T[] onTableChange: ({ sort, page }: Criteria) => void - sort: PropertySort + sort: PropertySort, + hideSelectableCheckboxes?: boolean, } function ItemList({ @@ -44,7 +45,8 @@ function ItemList({ loading, data: instances, onTableChange, - sort + sort, + hideSelectableCheckboxes, }: Props) { const [columns, setColumns] = useState[]>(columnsProp) const [selection, setSelection] = useState([]) @@ -175,7 +177,7 @@ function ItemList({ ` return ( -
+
visible)} diff --git a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.spec.tsx b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.spec.tsx index 3cfbe3802a..293f1d8f84 100644 --- a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.spec.tsx +++ b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.spec.tsx @@ -75,6 +75,26 @@ describe('INFINITE_MESSAGES', () => { expect(onCancel).toBeCalled() }) }) + + describe('DATABASE_IMPORT_FORBIDDEN', () => { + it('should render message', () => { + const { Inner } = INFINITE_MESSAGES.DATABASE_IMPORT_FORBIDDEN(jest.fn()) + expect(render(<>{Inner})).toBeTruthy() + }) + + it('should call onClose', () => { + const onClose = jest.fn() + const { Inner } = INFINITE_MESSAGES.DATABASE_IMPORT_FORBIDDEN(onClose) + render(<>{Inner}) + + fireEvent.click(screen.getByTestId('database-import-forbidden-notification-ok-btn')) + fireEvent.mouseUp(screen.getByTestId('database-import-forbidden-notification')) + fireEvent.mouseDown(screen.getByTestId('database-import-forbidden-notification')) + + expect(onClose).toBeCalled() + }) + }) + describe('SUBSCRIPTION_EXISTS', () => { it('should render message', () => { const { Inner } = INFINITE_MESSAGES.SUBSCRIPTION_EXISTS(jest.fn()) diff --git a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx index 2ff75fd89c..db6340d600 100644 --- a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx +++ b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx @@ -4,6 +4,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiLink, EuiLoadingSpinner, EuiSpacer, EuiText, @@ -29,6 +30,7 @@ export enum InfiniteMessagesIds { oAuthSuccess = 'oAuthSuccess', autoCreateDb = 'autoCreateDb', databaseExists = 'databaseExists', + databaseImportForbidden = 'databaseImportForbidden', subscriptionExists = 'subscriptionExists', appUpdateAvailable = 'appUpdateAvailable', pipelineDeploySuccess = 'pipelineDeploySuccess' @@ -219,6 +221,57 @@ export const INFINITE_MESSAGES = {
) }), + DATABASE_IMPORT_FORBIDDEN: (onClose?: () => void) => ({ + id: InfiniteMessagesIds.databaseImportForbidden, + Inner: ( +
{ e.preventDefault() }} + onMouseUp={(e) => { e.preventDefault() }} + data-testid="database-import-forbidden-notification" + > + + + Unable to import Cloud database. + + + + Adding your Redis Cloud database to Redis Insight is disabled due to + a setting restricting database connection management. + + + + Log in to + {' '} + + Redis Cloud + + {' '} + to check your database. + + + + + onClose?.()} + data-testid="database-import-forbidden-notification-ok-btn" + > + Ok + + + +
+ ) + }), SUBSCRIPTION_EXISTS: (onSuccess?: () => void, onClose?: () => void) => ({ id: InfiniteMessagesIds.subscriptionExists, Inner: ( diff --git a/redisinsight/ui/src/components/oauth/oauth-jobs/OAuthJobs.spec.tsx b/redisinsight/ui/src/components/oauth/oauth-jobs/OAuthJobs.spec.tsx index 68aff29ce1..f64da22db7 100644 --- a/redisinsight/ui/src/components/oauth/oauth-jobs/OAuthJobs.spec.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-jobs/OAuthJobs.spec.tsx @@ -204,6 +204,34 @@ describe('OAuthJobs', () => { ) }) + it('should call addInfiniteNotification and removeInfiniteNotification when errorCode is 11_115', async () => { + const error = { + errorCode: CustomErrorCodes.CloudDatabaseImportForbidden, + }; + (oauthCloudJobSelector as jest.Mock).mockImplementation(() => ({ + status: '' + })) + + const { rerender } = render(); + + (oauthCloudJobSelector as jest.Mock).mockImplementation(() => ({ + status: CloudJobStatus.Failed, + error, + })) + + rerender() + + const expectedActions = [ + addInfiniteNotification(INFINITE_MESSAGES.DATABASE_IMPORT_FORBIDDEN()), + setSSOFlow(), + setSocialDialogState(null), + removeInfiniteNotification(InfiniteMessagesIds.oAuthProgress), + ] + expect(clearStoreActions(store.getActions())).toEqual( + clearStoreActions(expectedActions) + ) + }) + it('should call logoutUser when statusCode is 401', async () => { const mockDatabaseId = '123' const error = { diff --git a/redisinsight/ui/src/components/oauth/oauth-jobs/OAuthJobs.tsx b/redisinsight/ui/src/components/oauth/oauth-jobs/OAuthJobs.tsx index 1e78f57501..47c7acbd4e 100644 --- a/redisinsight/ui/src/components/oauth/oauth-jobs/OAuthJobs.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-jobs/OAuthJobs.tsx @@ -72,6 +72,17 @@ const OAuthJobs = () => { break + case CustomErrorCodes.CloudDatabaseImportForbidden: + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_IMPORT_DATABASE_FORBIDDEN, + }) + + dispatch(addInfiniteNotification( + INFINITE_MESSAGES.DATABASE_IMPORT_FORBIDDEN(closeDatabaseImportForbidden) + )) + + break + case CustomErrorCodes.CloudSubscriptionAlreadyExistsFree: dispatch(addInfiniteNotification(INFINITE_MESSAGES.SUBSCRIPTION_EXISTS( () => createFreeDatabase(subscriptionId), @@ -123,6 +134,11 @@ const OAuthJobs = () => { })) } + const closeDatabaseImportForbidden = () => { + dispatch(setSSOFlow()) + dispatch(removeInfiniteNotification(InfiniteMessagesIds.databaseImportForbidden)) + } + const closeImportDatabase = () => { sendEventTelemetry({ event: TelemetryEvent.CLOUD_IMPORT_EXISTING_DATABASE_FORM_CLOSED, diff --git a/redisinsight/ui/src/constants/customErrorCodes.ts b/redisinsight/ui/src/constants/customErrorCodes.ts index ef6d39601a..5166947e34 100644 --- a/redisinsight/ui/src/constants/customErrorCodes.ts +++ b/redisinsight/ui/src/constants/customErrorCodes.ts @@ -32,6 +32,7 @@ export enum CustomErrorCodes { CloudTaskNotFound = 11_112, CloudJobNotFound = 11_113, CloudSubscriptionAlreadyExistsFree = 11_114, + CloudDatabaseImportForbidden = 11_115, // General database errors [11200, 11299] DatabaseAlreadyExists = 11_200, diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts index c506965f83..d7d592b584 100644 --- a/redisinsight/ui/src/constants/featureFlags.ts +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -8,4 +8,5 @@ export enum FeatureFlags { rdi = 'redisDataIntegration', hashFieldExpiration = 'hashFieldExpiration', enhancedCloudUI = 'enhancedCloudUI', + databaseManagement = 'databaseManagement', } diff --git a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.test.tsx b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.test.tsx index 6f782277a6..615b73bbab 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.test.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.test.tsx @@ -15,6 +15,7 @@ import { act, cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/util import { CREATE_CLOUD_DB_ID } from 'uiSrc/pages/home/constants' import { setSSOFlow } from 'uiSrc/slices/instances/cloud' import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' +import {appFeatureFlagsFeaturesSelector} from 'uiSrc/slices/app/features' import DatabasesListWrapper, { Props } from './DatabasesListWrapper' const mockedProps = mock() @@ -33,7 +34,10 @@ jest.mock('uiSrc/slices/app/features', () => ({ appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ cloudSso: { flag: true - } + }, + databaseManagement: { + flag: true, + }, }), })) @@ -229,4 +233,17 @@ describe('DatabasesListWrapper', () => { (sendEventTelemetry as jest.Mock).mockRestore() }) + + it('should hide management buttons when databaseManagement feature flag is disabled', async () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValue({ + databaseManagement: { + flag: false + } + }) + + const { queryByTestId } = render() + + expect(queryByTestId(/^edit-instance-/i)).not.toBeInTheDocument() + expect(queryByTestId(/^delete-instance-/i)).not.toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx index a9d4e9380b..ca5d69a7b7 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx @@ -56,6 +56,7 @@ import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import { getUtmExternalLink } from 'uiSrc/utils/links' import { CREATE_CLOUD_DB_ID, HELP_LINKS } from 'uiSrc/pages/home/constants' +import { FeatureFlagComponent } from 'uiSrc/components' import DbStatus from '../db-status' import styles from './styles.module.scss' @@ -88,7 +89,10 @@ const DatabasesListWrapper = (props: Props) => { const { theme } = useContext(ThemeContext) const { contextInstanceId } = useSelector(appContextSelector) - const { [FeatureFlags.cloudSso]: cloudSsoFeature } = useSelector(appFeatureFlagsFeaturesSelector) + const { + [FeatureFlags.cloudSso]: cloudSsoFeature, + [FeatureFlags.databaseManagement]: databaseManagementFeature, + } = useSelector(appFeatureFlagsFeaturesSelector) const [width, setWidth] = useState(0) const [, forceRerender] = useState({}) @@ -447,26 +451,28 @@ const DatabasesListWrapper = (props: Props) => { )} - handleClickEditInstance(instance)} - /> - handleDeleteInstance(instance)} - handleButtonClick={() => handleClickDeleteInstance(instance)} - testid={`delete-instance-${instance.id}`} - /> + + handleClickEditInstance(instance)} + /> + handleDeleteInstance(instance)} + handleButtonClick={() => handleClickDeleteInstance(instance)} + testid={`delete-instance-${instance.id}`} + /> + ) }, @@ -507,6 +513,7 @@ const DatabasesListWrapper = (props: Props) => { getSelectableItems={(item) => item.id !== 'create-free-cloud-db'} onTableChange={onTableChange} sort={sortingRef.current} + hideSelectableCheckboxes={!databaseManagementFeature?.flag} />
)} diff --git a/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss b/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss index 5811ee8358..00004986a0 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss @@ -96,6 +96,10 @@ $breakpoint-l: 1400px; visibility: hidden; } } + + :global(.hideSelectableCheckboxes .euiCheckbox) { + visibility: hidden; + } } .cloudIcon { diff --git a/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.spec.tsx b/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.spec.tsx index 3bb3b81ea1..a3a20c82e6 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.spec.tsx @@ -11,7 +11,10 @@ jest.mock('uiSrc/slices/app/features', () => ({ appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ enhancedCloudUI: { flag: false - } + }, + databaseManagement: { + flag: true, + }, }), })) @@ -66,4 +69,22 @@ describe('DatabaseListHeader', () => { expect(screen.getByTestId('promo-btn')).toBeInTheDocument() }) + + it('should show "create database" button when database management feature flag is enabled', () => { + const { queryByTestId } = render() + + expect(queryByTestId('add-redis-database-short')).toBeInTheDocument() + }) + + it('should hide "create database" button when database management feature flag is disabled', () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValue({ + databaseManagement: { + flag: false + } + }) + + const { queryByTestId } = render() + + expect(queryByTestId('add-redis-database-short')).not.toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.tsx b/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.tsx index 870139d579..3b4cb42049 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.tsx @@ -13,7 +13,7 @@ import { instancesSelector } from 'uiSrc/slices/instances/instances' import { OAuthSocialAction, OAuthSocialSource } from 'uiSrc/slices/interfaces' import PromoLink from 'uiSrc/components/promo-link/PromoLink' -import { OAuthSsoHandlerDialog } from 'uiSrc/components' +import { FeatureFlagComponent, OAuthSsoHandlerDialog } from 'uiSrc/components' import { getPathToResource } from 'uiSrc/services/resourcesService' import { ContentCreateRedis } from 'uiSrc/slices/interfaces/content' import { HELP_LINKS } from 'uiSrc/pages/home/constants' @@ -129,7 +129,9 @@ const DatabaseListHeader = ({ onAddInstance }: Props) => {
- + + + {!loading && !isEmpty(data) && ( diff --git a/redisinsight/ui/src/slices/app/features.ts b/redisinsight/ui/src/slices/app/features.ts index 26fee70134..813f145426 100644 --- a/redisinsight/ui/src/slices/app/features.ts +++ b/redisinsight/ui/src/slices/app/features.ts @@ -50,6 +50,9 @@ export const initialState: StateAppFeatures = { [FeatureFlags.enhancedCloudUI]: { flag: false }, + [FeatureFlags.databaseManagement]: { + flag: true + }, [FeatureFlags.envDependent]: { flag: riConfig.features.envDependent.defaultFlag } diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 38038ccf23..52c46dc337 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -288,6 +288,7 @@ export enum TelemetryEvent { CLOUD_API_KEY_REMOVED = 'CLOUD_API_KEY_REMOVED', CLOUD_LINK_CLICKED = 'CLOUD_LINK_CLICKED', CLOUD_IMPORT_EXISTING_DATABASE = 'CLOUD_IMPORT_EXISTING_DATABASE', + CLOUD_IMPORT_DATABASE_FORBIDDEN = 'CLOUD_IMPORT_DATABASE_FORBIDDEN', CLOUD_IMPORT_EXISTING_DATABASE_FORM_CLOSED = 'CLOUD_IMPORT_EXISTING_DATABASE_FORM_CLOSED', CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION = 'CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION', CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION_FORM_CLOSED = 'CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION_FORM_CLOSED',