From 22c4fa18d4f0a3eb1426a21ae6d253f4f4ee2e34 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 8 Nov 2022 10:42:12 +0300 Subject: [PATCH 01/57] #RI-3780 databases import. BE base implementation --- redisinsight/api/config/stack.ts | 1 + redisinsight/api/src/app.module.ts | 2 + .../database-import.controller.ts | 37 ++++++ .../database-import/database-import.module.ts | 11 ++ .../database-import.service.ts | 121 ++++++++++++++++++ .../dto/database-import.response.ts | 15 +++ .../dto/import.database.dto.ts | 3 + .../modules/database/database.controller.ts | 2 +- .../src/modules/database/database.module.ts | 1 + 9 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 redisinsight/api/src/modules/database-import/database-import.controller.ts create mode 100644 redisinsight/api/src/modules/database-import/database-import.module.ts create mode 100644 redisinsight/api/src/modules/database-import/database-import.service.ts create mode 100644 redisinsight/api/src/modules/database-import/dto/database-import.response.ts create mode 100644 redisinsight/api/src/modules/database-import/dto/import.database.dto.ts diff --git a/redisinsight/api/config/stack.ts b/redisinsight/api/config/stack.ts index 71e76d2b12..ea8df928a4 100644 --- a/redisinsight/api/config/stack.ts +++ b/redisinsight/api/config/stack.ts @@ -5,6 +5,7 @@ export default { excludeRoutes: [ 'redis-enterprise/*', 'redis-sentinel/*', + { path: 'databases/import' }, { path: 'databases', method: RequestMethod.POST }, { path: 'databases', method: RequestMethod.DELETE }, { path: 'databases/:id', method: RequestMethod.DELETE }, diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index 26dada6d44..8708586dd7 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -19,6 +19,7 @@ import { ServerModule } from 'src/modules/server/server.module'; import { LocalDatabaseModule } from 'src/local-database.module'; import { CoreModule } from 'src/core.module'; import { AutodiscoveryModule } from 'src/modules/autodiscovery/autodiscovery.module'; +import { DatabaseImportModule } from 'src/modules/database-import/database-import.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'; @@ -52,6 +53,7 @@ const PATH_CONFIG = config.get('dir_path'); BulkActionsModule, ClusterMonitorModule, DatabaseAnalysisModule, + DatabaseImportModule, ...(SERVER_CONFIG.staticContent ? [ ServeStaticModule.forRoot({ diff --git a/redisinsight/api/src/modules/database-import/database-import.controller.ts b/redisinsight/api/src/modules/database-import/database-import.controller.ts new file mode 100644 index 0000000000..3379fd7950 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.controller.ts @@ -0,0 +1,37 @@ +import { + Controller, Post, UploadedFile, UseInterceptors, UsePipes, ValidationPipe, +} from '@nestjs/common'; +import { + ApiBody, ApiConsumes, ApiResponse, ApiTags, +} from '@nestjs/swagger'; +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'; + +@UsePipes(new ValidationPipe({ transform: true })) +@ApiTags('Database') +@Controller('/databases') +export class DatabaseImportController { + constructor(private readonly service: DatabaseImportService) {} + + @Post('import') + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + @UseInterceptors(FileInterceptor('file')) + @ApiResponse({ type: DatabaseImportResponse }) + async import( + @UploadedFile() file: any, + ): Promise { + return this.service.import(file); + } +} diff --git a/redisinsight/api/src/modules/database-import/database-import.module.ts b/redisinsight/api/src/modules/database-import/database-import.module.ts new file mode 100644 index 0000000000..eac32e7d5f --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DatabaseImportController } from 'src/modules/database-import/database-import.controller'; +import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; + +@Module({ + controllers: [DatabaseImportController], + providers: [ + DatabaseImportService, + ], +}) +export class DatabaseImportModule {} diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts new file mode 100644 index 0000000000..507b5276fb --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -0,0 +1,121 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { isArray, get, set } from 'lodash'; +import { Database } from 'src/modules/database/models/database'; +import { plainToClass } from 'class-transformer'; +import { ConnectionType } from 'src/modules/database/entities/database.entity'; +import { DatabaseRepository } from 'src/modules/database/repositories/database.repository'; +import { DatabaseImportResponse } from 'src/modules/database-import/dto/database-import.response'; + +@Injectable() +export class DatabaseImportService { + private fieldsMapSchema: Array<[string, string[]]> = [ + ['name', ['name', 'connectionName']], + ['username', ['username']], + ['password', ['password', 'auth']], + ['host', ['host']], + ['port', ['port']], + ['db', ['db']], + ['isCluster', ['cluster']], + ]; + + constructor( + private readonly databaseRepository: DatabaseRepository, + ) {} + + public async import(file): Promise { + const items = DatabaseImportService.parseFile(file); + + if (!isArray(items) || !items?.length) { + let filename = file?.originalname || 'import file'; + if (filename.length > 50) { + filename = `${filename.slice(0, 50)}...`; + } + return Promise.reject(new BadRequestException(`Unable to parse ${filename}`)); + } + + const response = { + total: items.length, + success: 0, + }; + + // it is very important to insert databases on-by-one to avoid db constraint errors + await items.reduce((prev, item) => prev.finally(() => this.createDatabase(item) + .then(() => { + response.success += 1; + }) + .catch(() => { /* just ignore errors */ })), Promise.resolve()); + + return plainToClass(DatabaseImportResponse, response); + } + + /** + * Map data to known model, validate it and create database if possible + * Note: will not create connection, simply create database + * @param item + * @private + */ + private async createDatabase(item: any[]): Promise { + const data: any = {}; + + this.fieldsMapSchema.forEach(([field, paths]) => { + let value; + + paths.every((path) => { + value = get(item, path); + return value === undefined; + }); + + set(data, field, value); + }); + + // set database name if needed + if (!data.name) { + data.name = `${data.host}:${data.port}`; + } + + // determine database type + if (data.isCluster) { + data.connectionType = ConnectionType.CLUSTER; + } else { + data.connectionType = ConnectionType.STANDALONE; + } + + const database = plainToClass(Database, data); + + return this.databaseRepository.create(database); + } + + /** + * Try to parse file based on mimetype and known\supported formats + * @param file + */ + static parseFile(file): any { + const data = file?.buffer?.toString(); + + let databases; + + if (file.mimetype === 'application/json') { + databases = DatabaseImportService.parseJson(data); + } else { + databases = DatabaseImportService.parseBase64(data); + } + + return databases; + } + + static parseBase64(data: string): any { + try { + return JSON.parse((Buffer.from(data, 'base64')).toString('utf8')); + } catch (e) { + return null; + } + } + + static parseJson(data: string): any { + try { + return JSON.parse(data); + } catch (e) { + return null; + } + } +} diff --git a/redisinsight/api/src/modules/database-import/dto/database-import.response.ts b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts new file mode 100644 index 0000000000..f6ec120409 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class DatabaseImportResponse { + @ApiProperty({ + description: 'Total elements processed from the import file', + type: Number, + }) + total: number; + + @ApiProperty({ + description: 'Number of imported database', + type: Number, + }) + success: number; +} diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts new file mode 100644 index 0000000000..a2abaa7cac --- /dev/null +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -0,0 +1,3 @@ +export class ImportDatabaseDto { + +} diff --git a/redisinsight/api/src/modules/database/database.controller.ts b/redisinsight/api/src/modules/database/database.controller.ts index 7f99dfa4ce..da8e9e6c7b 100644 --- a/redisinsight/api/src/modules/database/database.controller.ts +++ b/redisinsight/api/src/modules/database/database.controller.ts @@ -16,7 +16,7 @@ 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'; -@ApiTags('Database Instances') +@ApiTags('Database') @Controller('databases') export class DatabaseController { constructor( diff --git a/redisinsight/api/src/modules/database/database.module.ts b/redisinsight/api/src/modules/database/database.module.ts index 286e20da18..110b12cacf 100644 --- a/redisinsight/api/src/modules/database/database.module.ts +++ b/redisinsight/api/src/modules/database/database.module.ts @@ -41,6 +41,7 @@ export class DatabaseModule { }, ], exports: [ + DatabaseRepository, DatabaseService, DatabaseConnectionService, // todo: rethink everything below From 97417a13e96d67b8433b18076b247d2fe2ca8d9c Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 9 Nov 2022 10:13:43 +0300 Subject: [PATCH 02/57] Change cluster connection + add data validation --- .../database-import.service.ts | 34 +++++++++++++++++-- .../dto/import.database.dto.ts | 7 ++-- .../database/database-connection.service.ts | 20 +++++++++-- .../api/src/modules/redis/redis.service.ts | 7 ++-- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index 507b5276fb..323f22f49b 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -1,13 +1,20 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { isArray, get, set } from 'lodash'; import { Database } from 'src/modules/database/models/database'; import { plainToClass } from 'class-transformer'; import { ConnectionType } from 'src/modules/database/entities/database.entity'; import { DatabaseRepository } from 'src/modules/database/repositories/database.repository'; import { DatabaseImportResponse } from 'src/modules/database-import/dto/database-import.response'; +import { Validator } from 'class-validator'; +import { ImportDatabaseDto } from 'src/modules/database-import/dto/import.database.dto'; +import { classToClass } from 'src/utils'; @Injectable() export class DatabaseImportService { + private logger = new Logger('DatabaseImportService'); + + private validator = new Validator(); + private fieldsMapSchema: Array<[string, string[]]> = [ ['name', ['name', 'connectionName']], ['username', ['username']], @@ -22,6 +29,10 @@ export class DatabaseImportService { private readonly databaseRepository: DatabaseRepository, ) {} + /** + * Import databases from the file + * @param file + */ public async import(file): Promise { const items = DatabaseImportService.parseFile(file); @@ -80,7 +91,26 @@ export class DatabaseImportService { data.connectionType = ConnectionType.STANDALONE; } - const database = plainToClass(Database, data); + const dto = plainToClass( + ImportDatabaseDto, + // additionally replace empty strings ("") with null + Object.keys(data) + .reduce((acc, key) => { + acc[key] = data[key] === '' ? null : data[key]; + return acc; + }, {}), + ); + + try { + await this.validator.validateOrReject(dto, { + whitelist: true, + }); + } catch (e) { + this.logger.warn('Invalid data for database import entry', e); + return Promise.reject(e); + } + + const database = classToClass(Database, dto); return this.databaseRepository.create(database); } diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index a2abaa7cac..dfb410531f 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -1,3 +1,6 @@ -export class ImportDatabaseDto { +import { PickType } from '@nestjs/swagger'; +import { Database } from 'src/modules/database/models/database'; -} +export class ImportDatabaseDto extends PickType(Database, [ + 'host', 'port', 'name', 'db', 'username', 'password', +] as const) {} diff --git a/redisinsight/api/src/modules/database/database-connection.service.ts b/redisinsight/api/src/modules/database/database-connection.service.ts index 0c9be37a1d..932458daaa 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.ts @@ -8,6 +8,7 @@ import { RedisService } from 'src/modules/redis/redis.service'; import { ClientMetadata } from 'src/modules/redis/models/client-metadata'; import { DatabaseService } from 'src/modules/database/database.service'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; +import { Database } from 'src/modules/database/models/database'; @Injectable() export class DatabaseConnectionService { @@ -38,10 +39,25 @@ export class DatabaseConnectionService { // refresh modules list and last connected time // will be refreshed after user navigate to particular database from the databases list // Note: move to a different place in case if we need to update such info more often - await this.repository.update(databaseId, { + const toUpdate: Partial = { lastConnection: new Date(), modules: await this.databaseInfoProvider.determineDatabaseModules(client), - }); + }; + + // !Temporary. Refresh cluster nodes on connection + if (client?.isCluster) { + const primaryNodeOptions = client.nodes('master')[0].options; + + toUpdate.host = primaryNodeOptions.host; + toUpdate.port = primaryNodeOptions.port; + + toUpdate.nodes = client.nodes().map(({ options }) => ({ + host: options.host, + port: options.port, + })); + } + + await this.repository.update(databaseId, toUpdate); this.logger.log(`Succeed to connect to database ${databaseId}`); } diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index 9199131181..1cd9fe10b6 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -96,14 +96,17 @@ export class RedisService { public async createClusterClient( database: Database, - nodes: IRedisClusterNodeAddress[], + nodes: IRedisClusterNodeAddress[] = [], useRetry: boolean = false, connectionName: string = CONNECTION_NAME_GLOBAL_PREFIX, ): Promise { const config = await this.getRedisConnectionConfig(database); return new Promise((resolve, reject) => { try { - const cluster = new Redis.Cluster(nodes, { + const cluster = new Redis.Cluster([{ + host: database.host, + port: database.port, + }].concat(nodes), { clusterRetryStrategy: useRetry ? this.retryStrategy : () => undefined, redisOptions: { ...config, From 3e168fee0036dbe26411b6c026a8ba40653aeaa0 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 10 Nov 2022 12:18:11 +0300 Subject: [PATCH 03/57] finilize + Itests --- .../database-import.controller.ts | 13 +- .../database-import.service.ts | 2 +- .../dto/import.database.dto.ts | 1 + .../src/modules/database/models/database.ts | 3 +- .../POST-databases-import.test.ts | 281 ++++++++++++++++++ redisinsight/api/test/helpers/test.ts | 6 + 6 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 redisinsight/api/test/api/database-import/POST-databases-import.test.ts 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 3379fd7950..4d1e9e5bfa 100644 --- a/redisinsight/api/src/modules/database-import/database-import.controller.ts +++ b/redisinsight/api/src/modules/database-import/database-import.controller.ts @@ -1,5 +1,7 @@ import { - Controller, Post, UploadedFile, UseInterceptors, UsePipes, ValidationPipe, + BadRequestException, + Controller, HttpCode, Post, UploadedFile, + UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiResponse, ApiTags, @@ -27,11 +29,20 @@ export class DatabaseImportController { }, }, }) + @HttpCode(200) @UseInterceptors(FileInterceptor('file')) @ApiResponse({ type: DatabaseImportResponse }) async import( @UploadedFile() file: any, ): Promise { + // todo: create FileValidation class + if (!file) { + throw new BadRequestException('No import file provided'); + } + if (file?.size > 1024 * 1024 * 10) { + throw new BadRequestException('Import file is too big. Maximum 10mb allowed'); + } + return this.service.import(file); } } diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index 323f22f49b..efa63f2e23 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -124,7 +124,7 @@ export class DatabaseImportService { let databases; - if (file.mimetype === 'application/json') { + if (file?.mimetype === 'application/json') { databases = DatabaseImportService.parseJson(data); } else { databases = DatabaseImportService.parseBase64(data); diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index dfb410531f..dbc83070d9 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -3,4 +3,5 @@ import { Database } from 'src/modules/database/models/database'; export class ImportDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', + 'connectionType', ] as const) {} diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index 27731772b7..8531689622 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -4,7 +4,7 @@ import { CaCertificate } from 'src/modules/certificate/models/ca-certificate'; import { ClientCertificate } from 'src/modules/certificate/models/client-certificate'; import { ConnectionType, HostingProvider } from 'src/modules/database/entities/database.entity'; import { - IsBoolean, + IsBoolean, IsEnum, IsInt, IsNotEmpty, IsNotEmptyObject, @@ -97,6 +97,7 @@ export class Database { enum: ConnectionType, }) @Expose() + @IsEnum(ConnectionType) connectionType: ConnectionType; @ApiPropertyOptional({ diff --git a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts new file mode 100644 index 0000000000..3786b9a10b --- /dev/null +++ b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts @@ -0,0 +1,281 @@ +import { + Joi, + expect, + describe, + it, + deps, + requirements, + validateApiCall, + getMainCheckFn, generateInvalidDataArray, +} from '../deps'; +import { randomBytes } from 'crypto'; +import { cloneDeep, set } from 'lodash'; +const { rte, request, server, localDb, constants } = deps; + +const endpoint = () => request(server).post(`/${constants.API.DATABASES}/import`); + +// input data schema +const databaseSchema = Joi.object({ + name: Joi.string().allow(null, ''), + host: Joi.string().required(), + port: Joi.number().integer().required(), + db: Joi.number().integer().allow(null, ''), + username: Joi.string().allow(null, ''), + password: Joi.string().allow(null, ''), +}).messages({ + 'any.required': '{#label} should not be empty', +}).strict(true); + +const validInputData = { + name: constants.getRandomString(), + host: constants.getRandomString(), + port: 111, +}; + +const baseDatabaseData = { + name: 'someName', + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: constants.TEST_REDIS_USER || '', + password: constants.TEST_REDIS_PASSWORD || '', +} + +const baseSentinelData = { + name: constants.TEST_SENTINEL_MASTER_GROUP, + username: constants.TEST_SENTINEL_MASTER_USER || null, + password: constants.TEST_SENTINEL_MASTER_PASS || null, +} + +const importDatabaseFormat1 = { + name: baseDatabaseData.name, + host: baseDatabaseData.host, + port: baseDatabaseData.port, + username: baseDatabaseData.username, + auth: baseDatabaseData.password, +} + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /databases/import', () => { + describe('Validation', function () { + generateInvalidDataArray(databaseSchema) + .map(({ path, value }) => { + const database = path?.length ? set(cloneDeep(validInputData), path, value) : value; + return { + name: `Should not import when database: ${path.join('.')} = "${value}"`, + attach: ['file', Buffer.from(JSON.stringify([database])), 'file.json'], + responseBody: { + total: 1, + success: 0, + } + } + }) + .map(async (testCase) => { + it(testCase.name, async () => { + await validateApiCall({ + endpoint, + ...testCase, + }); + }); + }); + + [ + { + name: 'Should fail due to file was not provided', + statusCode: 400, + responseBody: { + statusCode: 400, + message: 'No import file provided', + error: 'Bad Request', + }, + }, + { + name: 'Should fail due to file size (>10mb)', + statusCode: 400, + attach: ['file', randomBytes(11 * 1024 * 1024), 'filename.json'], + responseBody: { + statusCode: 400, + message: 'Import file is too big. Maximum 10mb allowed', + error: 'Bad Request', + }, + }, + { + name: 'Should fail to incorrect file format', + statusCode: 400, + attach: ['file', randomBytes(10), 'filename.json'], + responseBody: { + statusCode: 400, + message: 'Unable to parse filename.json', + error: 'Bad Request', + }, + }, + { + name: 'Should truncate error message', + statusCode: 400, + attach: ['file', randomBytes(10), new Array(10_000).fill(1).join('')], + responseBody: { + statusCode: 400, + message: `Unable to parse ${new Array(50).fill(1).join('')}...`, + error: 'Bad Request', + }, + }, + { + name: 'Should return 0/0 imported if mandatory field was not defined (host)', + statusCode: 400, + attach: ['file', randomBytes(10), new Array(10_000).fill(1).join('')], + responseBody: { + statusCode: 400, + message: `Unable to parse ${new Array(50).fill(1).join('')}...`, + error: 'Bad Request', + }, + }, + ].map(mainCheckFn); + }); + describe('STANDALONE', () => { + requirements('rte.type=STANDALONE'); + describe('NO TLS', function () { + requirements('!rte.tls'); + it('Import standalone without tls (format 1)', async () => { + const name = constants.getRandomString(); + + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: 1, + }, + }); + + // check connection + const database = await localDb.getInstanceByName(name); + await validateApiCall({ + endpoint: () => request(server).get(`/${constants.API.DATABASES}/${database.id}/connect`), + statusCode: 200, + }); + }); + describe('Oss', () => { + requirements('!rte.re'); + it('Import standalone with particular db index', async () => { + const name = constants.getRandomString(); + const cliUuid = constants.getRandomString(); + const browserKeyName = constants.getRandomString(); + const cliKeyName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + name, + db: constants.TEST_REDIS_DB_INDEX, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: 1, + }, + }); + + // check connection + const database = await localDb.getInstanceByName(name); + await validateApiCall({ + endpoint: () => request(server).get(`/${constants.API.DATABASES}/${database.id}/connect`), + statusCode: 200, + }); + + // Create string using Browser API to particular db index + await validateApiCall({ + endpoint: () => request(server).post(`/${constants.API.DATABASES}/${database.id}/string`), + statusCode: 201, + data: { + keyName: browserKeyName, + value: 'somevalue' + }, + }); + + // Create client for CLI + await validateApiCall({ + endpoint: () => request(server).patch(`/${constants.API.DATABASES}/${database.id}/cli/${cliUuid}`), + statusCode: 200, + }); + + // Create string using CLI API to 0 db index + await validateApiCall({ + endpoint: () => request(server).post(`/${constants.API.DATABASES}/${database.id}/cli/${cliUuid}/send-command`), + statusCode: 200, + data: { + command: `set ${cliKeyName} somevalue`, + }, + }); + + + // check data created by db index + await rte.data.executeCommand('select', `${constants.TEST_REDIS_DB_INDEX}`); + expect(await rte.data.executeCommand('exists', cliKeyName)).to.eql(1) + expect(await rte.data.executeCommand('exists', browserKeyName)).to.eql(1) + + // check data created by db index + await rte.data.executeCommand('select', '0'); + expect(await rte.data.executeCommand('exists', cliKeyName)).to.eql(0) + expect(await rte.data.executeCommand('exists', browserKeyName)).to.eql(0) + }); + }); + }); + xdescribe('TLS CA', function () { + requirements('rte.tls', '!rte.tlsAuth'); + }); + xdescribe('TLS AUTH', function () { + requirements('rte.tls', 'rte.tlsAuth'); + }); + }); + describe('CLUSTER', () => { + requirements('rte.type=CLUSTER'); + describe('NO TLS', function () { + requirements('!rte.tls'); + it('should import cluster database (base64)', async () => { + const name = constants.getRandomString(); + + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + name, + cluster: true, + } + ])).toString('base64')), 'file.ano'], + responseBody: { + total: 1, + success: 1, + }, + }); + + + // check connection + const database = await localDb.getInstanceByName(name); + + expect(database.nodes).to.eq('[]'); + expect(database.connectionType).to.eq('CLUSTER'); + + await validateApiCall({ + endpoint: () => request(server).get(`/${constants.API.DATABASES}/${database.id}/connect`), + statusCode: 200, + }); + + expect((await localDb.getInstanceByName(name)).nodes).to.not.eq('[]'); + }); + }); + xdescribe('TLS CA', function () { + requirements('rte.tls', '!rte.tlsAuth'); + }); + }); + xdescribe('SENTINEL', () => { + requirements('rte.type=SENTINEL'); + }); +}); diff --git a/redisinsight/api/test/helpers/test.ts b/redisinsight/api/test/helpers/test.ts index d1fd25ccce..7b73ca38e9 100644 --- a/redisinsight/api/test/helpers/test.ts +++ b/redisinsight/api/test/helpers/test.ts @@ -19,6 +19,7 @@ export * from './test/dataGenerator'; interface ITestCaseInput { endpoint: Function; // function that returns prepared supertest with url data?: any; + attach?: any[]; query?: any; statusCode?: number; responseSchema?: Joi.AnySchema; @@ -35,6 +36,7 @@ interface ITestCaseInput { export const validateApiCall = async function ({ endpoint, data, + attach, query, statusCode = 200, responseSchema, @@ -48,6 +50,10 @@ export const validateApiCall = async function ({ request.send(typeof data === 'function' ? data() : data); } + if (attach) { + request.attach(...attach); + } + // data to send with url query string if (query) { request.query(query); From ebde0a562d58dcdd9453ff82cdb7db7092939e5b Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Mon, 14 Nov 2022 15:22:18 +0400 Subject: [PATCH 04/57] #RI-3779 - add import db connections --- .../ImportDatabasesDialog.spec.tsx | 122 ++++++++++++ .../ImportDatabasesDialog.tsx | 170 ++++++++++++++++ .../import-databases-dialog/index.ts | 3 + .../styles.module.scss | 70 +++++++ redisinsight/ui/src/components/index.ts | 2 + redisinsight/ui/src/constants/api.ts | 1 + .../components/HomeHeader/HomeHeader.spec.tsx | 40 +++- .../home/components/HomeHeader/HomeHeader.tsx | 185 +++++++++++------- .../components/HomeHeader/styles.module.scss | 13 +- .../ui/src/slices/instances/instances.ts | 64 ++++++ .../ui/src/slices/interfaces/instances.ts | 8 + .../slices/tests/instances/instances.spec.ts | 157 +++++++++++++++ redisinsight/ui/src/telemetry/events.ts | 3 + 13 files changed, 766 insertions(+), 72 deletions(-) create mode 100644 redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx create mode 100644 redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx create mode 100644 redisinsight/ui/src/components/import-databases-dialog/index.ts create mode 100644 redisinsight/ui/src/components/import-databases-dialog/styles.module.scss diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx new file mode 100644 index 0000000000..61be09a4f4 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx @@ -0,0 +1,122 @@ +import { waitFor } from '@testing-library/react' +import { cloneDeep } from 'lodash' +import React from 'react' +import { importInstancesFromFile, importInstancesSelector } from 'uiSrc/slices/instances/instances' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/test-utils' + +import ImportDatabasesDialog from './ImportDatabasesDialog' + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + importInstancesSelector: jest.fn().mockReturnValue({ + loading: false, + error: '', + data: null + }) +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('ImportDatabasesDialog', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call onClose', () => { + const onClose = jest.fn() + render() + + fireEvent.click(screen.getByTestId('cancel-btn')) + + expect(onClose).toBeCalled() + }) + + it('submit btn should be disabled without file', () => { + render() + + expect(screen.getByTestId('submit-btn')).toBeDisabled() + }) + + it('should call proper actions and send telemetry', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + + render() + + const jsonString = JSON.stringify({}) + const blob = new Blob([jsonString]) + const file = new File([blob], 'empty.json', { + type: 'application/JSON', + }) + + await waitFor(() => { + fireEvent.change( + screen.getByTestId('import-databases-input-file'), + { + target: { files: [file] }, + } + ) + }) + + expect(screen.getByTestId('submit-btn')).not.toBeDisabled() + fireEvent.click(screen.getByTestId('submit-btn')) + + const expectedActions = [importInstancesFromFile()] + expect(store.getActions()).toEqual(expectedActions) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_SUBMITTED + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('should render loading indicator', () => { + (importInstancesSelector as jest.Mock).mockImplementation(() => ({ + loading: true, + data: null + })) + + render() + expect(screen.getByTestId('file-loading-indicator')).toBeInTheDocument() + }) + + it('should render success message when at least 1 database added', () => { + (importInstancesSelector as jest.Mock).mockImplementation(() => ({ + loading: false, + data: { + success: 1, + total: 2 + } + })) + + render() + expect(screen.getByTestId('result-success')).toBeInTheDocument() + expect(screen.queryByTestId('result-failed')).not.toBeInTheDocument() + }) + + it('should render error message when 0 success databases added', () => { + (importInstancesSelector as jest.Mock).mockImplementation(() => ({ + loading: false, + data: { + success: 0, + total: 2 + } + })) + + render() + expect(screen.getByTestId('result-failed')).toBeInTheDocument() + expect(screen.queryByTestId('result-success')).not.toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx new file mode 100644 index 0000000000..ea91f9f781 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx @@ -0,0 +1,170 @@ +import { + EuiButton, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiText, + EuiTextColor, + EuiTitle +} from '@elastic/eui' +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + fetchInstancesAction, + importInstancesSelector, + resetImportInstances, + uploadInstancesFile +} from 'uiSrc/slices/instances/instances' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { Nullable } from 'uiSrc/utils' + +import styles from './styles.module.scss' + +export interface Props { + onClose: (isCancelled: boolean) => void +} + +const MAX_MB_FILE = 10 +const MAX_FILE_SIZE = MAX_MB_FILE * 1024 * 1024 + +const ImportDatabasesDialog = ({ onClose }: Props) => { + const { loading, data, error } = useSelector(importInstancesSelector) + const [files, setFiles] = useState>(null) + const [isInvalid, setIsInvalid] = useState(false) + const [isSubmitDisabled, setIsSubmitDisabled] = useState(true) + + const dispatch = useDispatch() + + const onFileChange = (files: FileList | null) => { + setFiles(files) + setIsInvalid(!!files?.length && files?.[0].size > MAX_FILE_SIZE) + setIsSubmitDisabled(!files?.length || files[0].size > MAX_FILE_SIZE) + } + + const handleOnClose = () => { + dispatch(resetImportInstances()) + data?.success && dispatch(fetchInstancesAction()) + onClose(!data) + } + + const onSubmit = () => { + if (files) { + const formData = new FormData() + formData.append('file', files[0]) + + dispatch(uploadInstancesFile(formData)) + + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_SUBMITTED + }) + } + } + + const isShowForm = !loading && !data && !error + return ( + + + + + Import Database Connections + + + + + + + + {isShowForm && ( + <> + + {isInvalid && ( + + File should not exceed {MAX_MB_FILE} mb + + )} + + )} + + {loading && ( +
+ + Uploading... +
+ )} + + {data && data.success !== 0 && ( +
+ + + Successfully added {data.success} of {data.total} database connections + +
+ )} + + {(data?.success === 0 || error) && ( +
+ + + Failed to add database connections + +
+ )} +
+
+
+ + {data && data.success !== 0 && ( + + + Ok + + + )} + + {isShowForm && ( + + + Cancel + + + + Import + + + )} +
+ ) +} + +export default ImportDatabasesDialog diff --git a/redisinsight/ui/src/components/import-databases-dialog/index.ts b/redisinsight/ui/src/components/import-databases-dialog/index.ts new file mode 100644 index 0000000000..2a083df701 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/index.ts @@ -0,0 +1,3 @@ +import ImportDatabasesDialog from './ImportDatabasesDialog' + +export default ImportDatabasesDialog diff --git a/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss new file mode 100644 index 0000000000..3f240cd6bf --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss @@ -0,0 +1,70 @@ +.modal { + background: var(--euiColorLightestShade) !important; + min-width: 500px !important; + min-height: 270px !important; + + :global { + .euiModalHeader { + padding: 4px 42px 42px 30px; + } + + .euiModalBody__overflow { + padding: 8px 30px; + } + + .euiModal__closeIcon { + top: 16px; + right: 16px; + background: none; + } + + .euiButtonEmpty.euiButtonEmpty--primary.euiFilePicker__clearButton, + .euiButtonEmpty.euiButtonEmpty--primary.euiFilePicker__clearButton .euiButtonEmpty__text { + color: var(--externalLinkColor) !important; + } + + .euiModalFooter { + margin-top: 12px; + } + } + + .errorFileMsg { + margin-top: 10px; + font-size: 12px; + } + + .fileDrop { + width: 300px; + + :global { + .euiFilePicker__showDrop .euiFilePicker__prompt, .euiFilePicker__input:focus + .euiFilePicker__prompt { + background-color: var(--euiColorEmptyShade); + } + + .euiFilePicker__prompt { + background-color: var(--euiColorEmptyShade); + height: 140px; + border-radius: 4px; + box-shadow: none; + border: 1px dashed var(--controlsBorderColor); + } + + .euiFilePicker { + width: 400px; + } + + .euiFilePicker__clearButton { + margin-top: 4px; + } + } + } + + .loading, .result { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + margin-top: 20px; + } +} diff --git a/redisinsight/ui/src/components/index.ts b/redisinsight/ui/src/components/index.ts index 3cfce1e0af..567df24019 100644 --- a/redisinsight/ui/src/components/index.ts +++ b/redisinsight/ui/src/components/index.ts @@ -20,6 +20,7 @@ import GlobalSubscriptions from './global-subscriptions' import MonitorWrapper from './monitor' import PagePlaceholder from './page-placeholder' import BulkActionsConfig from './bulk-actions-config' +import ImportDatabasesDialog from './import-databases-dialog' export { NavigationMenu, @@ -47,4 +48,5 @@ export { ShortcutsFlyout, PagePlaceholder, BulkActionsConfig, + ImportDatabasesDialog } diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index f910f787af..b1f78075f8 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -1,5 +1,6 @@ enum ApiEndpoints { DATABASES = 'databases', + DATABASES_IMPORT = 'databases/import', CA_CERTIFICATES = 'certificates/ca', CLIENT_CERTIFICATES = 'certificates/client', diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.spec.tsx b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.spec.tsx index 5b8613f679..a827593487 100644 --- a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.spec.tsx @@ -1,6 +1,8 @@ +import { within } from '@testing-library/react' import React from 'react' import { instance, mock } from 'ts-mockito' -import { render } from 'uiSrc/utils/test-utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' import HomeHeader, { Props } from './HomeHeader' const mockedProps = mock() @@ -16,8 +18,44 @@ jest.mock('uiSrc/slices/content/create-redis-buttons', () => { } }) +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + describe('HomeHeader', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + it('should open import dbs dialog', () => { + render() + + fireEvent.click(screen.getByTestId('import-dbs-btn')) + + expect(screen.getByTestId('import-dbs-dialog')).toBeInTheDocument() + }) + + it('should call proper telemetry on open and close import databases dialog', () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + + render() + + fireEvent.click(screen.getByTestId('import-dbs-btn')) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_CLICKED + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + + fireEvent.click(within(screen.getByTestId('import-dbs-dialog')).getByTestId('cancel-btn')) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_CANCELLED + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) }) diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx index a3aa3dbcce..959ffc6da9 100644 --- a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx @@ -3,12 +3,15 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, + EuiIcon, EuiSpacer, EuiText, + EuiToolTip, } from '@elastic/eui' import { isEmpty } from 'lodash' import { useSelector } from 'react-redux' import cx from 'classnames' +import { ImportDatabasesDialog } from 'uiSrc/components' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import HelpLinksMenu from 'uiSrc/pages/home/components/HelpLinksMenu' import PromoLink from 'uiSrc/components/promo-link/PromoLink' @@ -37,6 +40,7 @@ const HomeHeader = ({ onAddInstance, direction, welcomePage = false }: Props) => const { loading, data } = useSelector(contentSelector) const [promoData, setPromoData] = useState() const [guides, setGuides] = useState([]) + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) useEffect(() => { if (loading || !data || isEmpty(data)) { @@ -74,6 +78,20 @@ const HomeHeader = ({ onAddInstance, direction, welcomePage = false }: Props) => } } + const handleClickImportDbBtn = () => { + setIsImportDialogOpen(true) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_CLICKED, + }) + } + + const handleCloseImportDb = (isCancelled: boolean) => { + setIsImportDialogOpen(false) + isCancelled && sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_CANCELLED, + }) + } + const AddInstanceBtn = () => ( <> ) + const ImportDatabasesBtn = () => ( + + + + + + ) + const Guides = () => (
@@ -150,82 +185,92 @@ const HomeHeader = ({ onAddInstance, direction, welcomePage = false }: Props) => ) } - return direction === 'column' - ? ( -
- - - - - - - -
- - - { !loading && !isEmpty(data) && ( - <> - {promoData && ( - - - - - - )} - - - )} - -
- ) : ( -
- - - - - -
- - { !loading && !isEmpty(data) && ( + return ( + <> + {isImportDialogOpen && } + {direction === 'column' ? ( +
+ + + + + + + + + + +
+ + + {!loading && !isEmpty(data) && ( <> - - - {promoData && ( - - - - )} - - + {promoData && ( + + + - - - handleClickLink(HELP_LINKS[link as keyof typeof HELP_LINKS]?.event)} - /> - - - handleClickLink(HELP_LINKS[link as keyof typeof HELP_LINKS]?.event)} - /> - + )} + )} - {instances.length > 0 && ( - - + +
+ ) : ( +
+ + + - )} - - -
- ) + + + + +
+ + {!loading && !isEmpty(data) && ( + <> + + + {promoData && ( + + + + )} + + + + + + + handleClickLink(HELP_LINKS[link as keyof typeof HELP_LINKS]?.event)} + /> + + + handleClickLink(HELP_LINKS[link as keyof typeof HELP_LINKS]?.event)} + /> + + + )} + {instances.length > 0 && ( + + + + )} + + +
+ )} + + ) } export default HomeHeader diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/styles.module.scss b/redisinsight/ui/src/pages/home/components/HomeHeader/styles.module.scss index c4fb2a4d8e..a4ca7d5f5a 100644 --- a/redisinsight/ui/src/pages/home/components/HomeHeader/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/styles.module.scss @@ -21,6 +21,17 @@ font-weight: 500 !important; } } + +.importDatabasesBtn:global(.euiButton) { + height: 43px; + min-width: auto !important; + + :global(.euiButton__text) { + display: flex; + align-items: center; + } +} + .followText { padding-top: 7px; font-size: 12px !important; @@ -147,7 +158,7 @@ .smallGuides { display: none !important; - @media (min-width: 1101px) and (max-width: 1251px) { + @media (min-width: 1101px) and (max-width: 1249px) { display: flex !important; } } diff --git a/redisinsight/ui/src/slices/instances/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts index d23e69f67e..8031e97067 100644 --- a/redisinsight/ui/src/slices/instances/instances.ts +++ b/redisinsight/ui/src/slices/instances/instances.ts @@ -43,6 +43,11 @@ export const initialState: InitialStateInstances = { instanceOverview: { version: '', }, + importInstances: { + loading: false, + error: '', + data: null + }, } // A slice for recipes @@ -169,6 +174,25 @@ const instancesSlice = createSlice({ resetConnectedInstance: (state) => { state.connectedInstance = initialState.connectedInstance }, + + importInstancesFromFile: (state) => { + state.importInstances.loading = true + state.importInstances.error = '' + }, + + importInstancesFromFileSuccess: (state, { payload }) => { + state.importInstances.loading = false + state.importInstances.data = payload + }, + + importInstancesFromFileFailure: (state, { payload }) => { + state.importInstances.loading = false + state.importInstances.error = payload + }, + + resetImportInstances: (state) => { + state.importInstances = initialState.importInstances + } }, }) @@ -196,6 +220,10 @@ export const { changeInstanceAliasFailure, resetInstanceUpdate, setEditedInstance, + importInstancesFromFile, + importInstancesFromFileSuccess, + importInstancesFromFileFailure, + resetImportInstances } = instancesSlice.actions // selectors @@ -206,6 +234,8 @@ export const editedInstanceSelector = (state: RootState) => state.connections.instances.editedInstance export const connectedInstanceOverviewSelector = (state: RootState) => state.connections.instances.instanceOverview +export const importInstancesSelector = (state: RootState) => + state.connections.instances.importInstances // The reducer export default instancesSlice.reducer @@ -484,3 +514,37 @@ export function resetInstanceUpdateAction() { sourceInstance?.cancel?.() } } + +// Asynchronous thunk action +export function uploadInstancesFile( + file: FormData, + onSuccessAction?: () => void, + onFailAction?: () => void +) { + return async (dispatch: AppDispatch) => { + dispatch(importInstancesFromFile()) + + try { + const { status, data } = await apiService.post( + ApiEndpoints.DATABASES_IMPORT, + file, + { + headers: { + Accept: 'application/json', + 'Content-Type': 'multipart/form-data' + } + } + ) + + if (isStatusSuccessful(status)) { + dispatch(importInstancesFromFileSuccess(data)) + onSuccessAction?.() + } + } catch (error) { + const errorMessage = getApiErrorMessage(error) + dispatch(importInstancesFromFileFailure(errorMessage)) + dispatch(addErrorNotification(error)) + onFailAction?.() + } + } +} diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 438e0f5231..2c591ea96a 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -283,6 +283,14 @@ export interface InitialStateInstances { connectedInstance: Instance editedInstance: InitialStateEditedInstances instanceOverview: DatabaseConfigInfo + importInstances: { + loading: boolean + error: string + data: Nullable<{ + success: number, + total: number + }> + } } export interface InitialStateEditedInstances { diff --git a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts index 97d8bb10fb..a6073bbc65 100644 --- a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts @@ -43,6 +43,11 @@ import reducer, { setConnectedInstance, setConnectedInstanceFailure, setConnectedInstanceSuccess, + importInstancesFromFile, + importInstancesFromFileSuccess, + importInstancesFromFileFailure, + resetImportInstances, + importInstancesSelector, uploadInstancesFile } from '../../instances/instances' import { addErrorNotification, addMessageNotification, IAddInstanceErrorPayload } from '../../app/notifications' import { ConnectionType, InitialStateInstances, Instance } from '../../interfaces' @@ -512,6 +517,108 @@ describe('instances slice', () => { }) }) + describe('importInstancesFromFile', () => { + it('should properly set state', () => { + // Arrange + const state = { + ...initialState.importInstances, + loading: true, + error: '' + } + + // Act + const nextState = reducer(initialState, importInstancesFromFile()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(importInstancesSelector(rootState)).toEqual(state) + }) + }) + + describe('importInstancesFromFileSuccess', () => { + it('should properly set state', () => { + // Arrange + const data = { + success: 3, + total: 5 + } + const state = { + ...initialState.importInstances, + loading: false, + data + } + + // Act + const nextState = reducer(initialState, importInstancesFromFileSuccess(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(importInstancesSelector(rootState)).toEqual(state) + }) + }) + + describe('importInstancesFromFileFailure', () => { + it('should properly set state', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState.importInstances, + loading: false, + error + } + + // Act + const nextState = reducer(initialState, importInstancesFromFileFailure(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(importInstancesSelector(rootState)).toEqual(state) + }) + }) + + describe('resetImportInstances', () => { + it('should properly set state', () => { + // Arrange + const currentState = { + ...initialState, + importInstances: { + ...initialState.importInstances, + data: { + success: 1, + total: 2 + } + } + } + + const state = { + ...initialState.importInstances + } + + // Act + const nextState = reducer(currentState, resetImportInstances()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + instances: nextState, + }, + }) + expect(importInstancesSelector(rootState)).toEqual(state) + }) + }) + describe('thunks', () => { describe('fetchInstances', () => { it('call both fetchInstances and loadInstancesSuccess when fetch is successed', async () => { @@ -997,5 +1104,55 @@ describe('instances slice', () => { expect(store.getActions()).toEqual(expectedActions) }) }) + + describe('uploadInstancesFile', () => { + it('should call proper actions on success', async () => { + // Arrange + const formData = new FormData() + const data = { + success: 0, + total: 1 + } + + const responsePayload = { data, status: 200 } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(uploadInstancesFile(formData)) + + // Assert + const expectedActions = [ + importInstancesFromFile(), + importInstancesFromFileSuccess(responsePayload.data) + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call proper actions on fail', async () => { + // Arrange + const formData = new FormData() + const errorMessage = 'Some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValueOnce(responsePayload) + + // Act + await store.dispatch(uploadInstancesFile(formData)) + + // Assert + const expectedActions = [ + importInstancesFromFile(), + importInstancesFromFileFailure(responsePayload.response.data.message), + addErrorNotification(responsePayload as AxiosError), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) }) }) diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index ea9b2bf88f..1185656b4c 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -27,6 +27,9 @@ export enum TelemetryEvent { CONFIG_DATABASES_DATABASE_CLONE_REQUESTED = 'CONFIG_DATABASES_DATABASE_CLONE_REQUESTED', CONFIG_DATABASES_DATABASE_CLONE_CANCELLED = 'CONFIG_DATABASES_DATABASE_CLONE_CANCELLED', CONFIG_DATABASES_DATABASE_CLONE_CONFIRMED = 'CONFIG_DATABASES_DATABASE_CLONE_CONFIRMED', + CONFIG_DATABASES_REDIS_IMPORT_SUBMITTED = 'CONFIG_DATABASES_REDIS_IMPORT_SUBMITTED', + CONFIG_DATABASES_REDIS_IMPORT_CANCELLED = 'CONFIG_DATABASES_REDIS_IMPORT_CANCELLED', + CONFIG_DATABASES_REDIS_IMPORT_CLICKED = 'CONFIG_DATABASES_REDIS_IMPORT_CLICKED', BUILD_FROM_SOURCE_CLICKED = 'BUILD_FROM_SOURCE_CLICKED', BUILD_USING_DOCKER_CLICKED = 'BUILD_USING_DOCKER_CLICKED', From ce231de85cc2af8d19d6e905ba8a5d8d5a6ca05a Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 14 Nov 2022 17:22:28 +0300 Subject: [PATCH 05/57] Fix validation + add telemetry --- .../api/src/constants/telemetry-events.ts | 5 ++ .../database-import.analytics.ts | 42 ++++++++++ .../database-import.controller.ts | 9 -- .../database-import/database-import.module.ts | 2 + .../database-import.service.ts | 83 +++++++++++++------ .../dto/database-import.response.ts | 6 ++ .../dto/import.database.dto.ts | 10 ++- .../database-import/exceptions/index.ts | 3 + ...database-import-file-provided.exception.ts | 13 +++ ...exceeded-database-import-file.exception.ts | 13 +++ ...to-parse-database-import-file.exception.ts | 13 +++ .../POST-databases-import.test.ts | 14 ++-- 12 files changed, 169 insertions(+), 44 deletions(-) create mode 100644 redisinsight/api/src/modules/database-import/database-import.analytics.ts create mode 100644 redisinsight/api/src/modules/database-import/exceptions/index.ts create mode 100644 redisinsight/api/src/modules/database-import/exceptions/no-database-import-file-provided.exception.ts create mode 100644 redisinsight/api/src/modules/database-import/exceptions/size-limit-exceeded-database-import-file.exception.ts create mode 100644 redisinsight/api/src/modules/database-import/exceptions/unable-to-parse-database-import-file.exception.ts diff --git a/redisinsight/api/src/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index 307a74ea47..8dff4b10e4 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -14,6 +14,11 @@ export enum TelemetryEvents { RedisInstanceConnectionFailed = 'DATABASE_CONNECTION_FAILED', RedisInstanceListReceived = 'CONFIG_DATABASES_DATABASE_LIST_DISPLAYED', + // Databases import + DatabaseImportParseFailed = 'CONFIG_DATABASES_REDIS_IMPORT_PARSE_FAILED', + DatabaseImportFailed = 'CONFIG_DATABASES_REDIS_IMPORT_FAILED', + DatabaseImportSucceeded = 'CONFIG_DATABASES_REDIS_IMPORT_SUCCEEDED', + // Events for autodiscovery flows REClusterDiscoverySucceed = 'CONFIG_DATABASES_RE_CLUSTER_AUTODISCOVERY_SUCCEEDED', REClusterDiscoveryFailed = 'CONFIG_DATABASES_RE_CLUSTER_AUTODISCOVERY_FAILED', diff --git a/redisinsight/api/src/modules/database-import/database-import.analytics.ts b/redisinsight/api/src/modules/database-import/database-import.analytics.ts new file mode 100644 index 0000000000..e363db5a0e --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.analytics.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { TelemetryBaseService } from 'src/modules/analytics/telemetry.base.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { DatabaseImportResponse } from 'src/modules/database-import/dto/database-import.response'; + +@Injectable() +export class DatabaseImportAnalytics extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendImportResults(importResult: DatabaseImportResponse): void { + if (importResult.success) { + this.sendEvent( + TelemetryEvents.DatabaseImportSucceeded, + { + succeed: importResult.success, + }, + ); + } + + if (importResult.errors?.length) { + this.sendEvent( + TelemetryEvents.DatabaseImportFailed, + { + failed: importResult.errors.length, + errors: importResult.errors.map((e) => (e?.constructor?.name || 'UncaughtError')), + }, + ); + } + } + + sendImportFailed(e: Error): void { + this.sendEvent( + TelemetryEvents.DatabaseImportParseFailed, + { + error: e?.constructor?.name || 'UncaughtError', + }, + ); + } +} 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 4d1e9e5bfa..3bfabfb86e 100644 --- a/redisinsight/api/src/modules/database-import/database-import.controller.ts +++ b/redisinsight/api/src/modules/database-import/database-import.controller.ts @@ -1,5 +1,4 @@ import { - BadRequestException, Controller, HttpCode, Post, UploadedFile, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; @@ -35,14 +34,6 @@ export class DatabaseImportController { async import( @UploadedFile() file: any, ): Promise { - // todo: create FileValidation class - if (!file) { - throw new BadRequestException('No import file provided'); - } - if (file?.size > 1024 * 1024 * 10) { - throw new BadRequestException('Import file is too big. Maximum 10mb allowed'); - } - return this.service.import(file); } } diff --git a/redisinsight/api/src/modules/database-import/database-import.module.ts b/redisinsight/api/src/modules/database-import/database-import.module.ts index eac32e7d5f..ae7e036f30 100644 --- a/redisinsight/api/src/modules/database-import/database-import.module.ts +++ b/redisinsight/api/src/modules/database-import/database-import.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { DatabaseImportController } from 'src/modules/database-import/database-import.controller'; import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; +import { DatabaseImportAnalytics } from 'src/modules/database-import/database-import.analytics'; @Module({ controllers: [DatabaseImportController], providers: [ DatabaseImportService, + DatabaseImportAnalytics, ], }) export class DatabaseImportModule {} diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index efa63f2e23..d83b14efb7 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { isArray, get, set } from 'lodash'; import { Database } from 'src/modules/database/models/database'; import { plainToClass } from 'class-transformer'; @@ -8,6 +8,11 @@ import { DatabaseImportResponse } from 'src/modules/database-import/dto/database import { Validator } from 'class-validator'; import { ImportDatabaseDto } from 'src/modules/database-import/dto/import.database.dto'; import { classToClass } from 'src/utils'; +import { DatabaseImportAnalytics } from 'src/modules/database-import/database-import.analytics'; +import { + SizeLimitExceededDatabaseImportFileException, + NoDatabaseImportFileProvidedException, UnableToParseDatabaseImportFileException, +} from 'src/modules/database-import/exceptions'; @Injectable() export class DatabaseImportService { @@ -27,6 +32,7 @@ export class DatabaseImportService { constructor( private readonly databaseRepository: DatabaseRepository, + private readonly analytics: DatabaseImportAnalytics, ) {} /** @@ -34,29 +40,57 @@ export class DatabaseImportService { * @param file */ public async import(file): Promise { - const items = DatabaseImportService.parseFile(file); + try { + // todo: create FileValidation class + if (!file) { + throw new NoDatabaseImportFileProvidedException('No import file provided'); + } + if (file?.size > 1024 * 1024 * 10) { + throw new SizeLimitExceededDatabaseImportFileException('Import file is too big. Maximum 10mb allowed'); + } - if (!isArray(items) || !items?.length) { - let filename = file?.originalname || 'import file'; - if (filename.length > 50) { - filename = `${filename.slice(0, 50)}...`; + const items = DatabaseImportService.parseFile(file); + + if (!isArray(items) || !items?.length) { + let filename = file?.originalname || 'import file'; + if (filename.length > 50) { + filename = `${filename.slice(0, 50)}...`; + } + throw new UnableToParseDatabaseImportFileException(`Unable to parse ${filename}`); } - return Promise.reject(new BadRequestException(`Unable to parse ${filename}`)); - } - const response = { - total: items.length, - success: 0, - }; + let response = { + total: items.length, + success: 0, + errors: [], + }; + + // it is very important to insert databases on-by-one to avoid db constraint errors + await items.reduce((prev, item) => prev.finally(() => this.createDatabase(item) + .then(() => { + response.success += 1; + }) + .catch((e) => { + let error = e; + if (isArray(e)) { + [error] = e; + } + this.logger.warn(`Unable to import database: ${error?.constructor?.name || 'UncaughtError'}`, error); + response.errors.push(error); + })), Promise.resolve()); + + this.analytics.sendImportResults(response); + + response = plainToClass(DatabaseImportResponse, response); + + return response; + } catch (e) { + this.logger.warn(`Unable to import databases: ${e?.constructor?.name || 'UncaughtError'}`, e); - // it is very important to insert databases on-by-one to avoid db constraint errors - await items.reduce((prev, item) => prev.finally(() => this.createDatabase(item) - .then(() => { - response.success += 1; - }) - .catch(() => { /* just ignore errors */ })), Promise.resolve()); + this.analytics.sendImportFailed(e); - return plainToClass(DatabaseImportResponse, response); + throw e; + } } /** @@ -101,14 +135,9 @@ export class DatabaseImportService { }, {}), ); - try { - await this.validator.validateOrReject(dto, { - whitelist: true, - }); - } catch (e) { - this.logger.warn('Invalid data for database import entry', e); - return Promise.reject(e); - } + await this.validator.validateOrReject(dto, { + whitelist: true, + }); const database = classToClass(Database, dto); diff --git a/redisinsight/api/src/modules/database-import/dto/database-import.response.ts b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts index f6ec120409..dfe8f374da 100644 --- a/redisinsight/api/src/modules/database-import/dto/database-import.response.ts +++ b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts @@ -1,15 +1,21 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Exclude, Expose } from 'class-transformer'; export class DatabaseImportResponse { @ApiProperty({ description: 'Total elements processed from the import file', type: Number, }) + @Expose() total: number; @ApiProperty({ description: 'Number of imported database', type: Number, }) + @Expose() success: number; + + @Exclude() + errors: Error[]; } diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index dbc83070d9..7fd5f757fd 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -1,7 +1,15 @@ import { PickType } from '@nestjs/swagger'; import { Database } from 'src/modules/database/models/database'; +import { Expose, Type } from 'class-transformer'; +import { IsInt, IsNotEmpty } from 'class-validator'; export class ImportDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', 'connectionType', -] as const) {} +] as const) { + @Expose() + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + port: number; +} diff --git a/redisinsight/api/src/modules/database-import/exceptions/index.ts b/redisinsight/api/src/modules/database-import/exceptions/index.ts new file mode 100644 index 0000000000..d62d9a0259 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/index.ts @@ -0,0 +1,3 @@ +export * from './size-limit-exceeded-database-import-file.exception'; +export * from './no-database-import-file-provided.exception'; +export * from './unable-to-parse-database-import-file.exception'; diff --git a/redisinsight/api/src/modules/database-import/exceptions/no-database-import-file-provided.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/no-database-import-file-provided.exception.ts new file mode 100644 index 0000000000..8a69a18cc4 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/no-database-import-file-provided.exception.ts @@ -0,0 +1,13 @@ +import { HttpException } from '@nestjs/common'; + +export class NoDatabaseImportFileProvidedException extends HttpException { + constructor(message: string = 'No import file provided') { + const response = { + message, + statusCode: 400, + error: 'No Database Import File Provided', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/src/modules/database-import/exceptions/size-limit-exceeded-database-import-file.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/size-limit-exceeded-database-import-file.exception.ts new file mode 100644 index 0000000000..0dfb0b960c --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/size-limit-exceeded-database-import-file.exception.ts @@ -0,0 +1,13 @@ +import { HttpException } from '@nestjs/common'; + +export class SizeLimitExceededDatabaseImportFileException extends HttpException { + constructor(message: string = 'Invalid import file') { + const response = { + message, + statusCode: 400, + error: 'Invalid Database Import File', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/src/modules/database-import/exceptions/unable-to-parse-database-import-file.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/unable-to-parse-database-import-file.exception.ts new file mode 100644 index 0000000000..e73f1de712 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/unable-to-parse-database-import-file.exception.ts @@ -0,0 +1,13 @@ +import { HttpException } from '@nestjs/common'; + +export class UnableToParseDatabaseImportFileException extends HttpException { + constructor(message: string = 'Unable to parse import file') { + const response = { + message, + statusCode: 400, + error: 'Unable To Parse Database Import File', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts index 3786b9a10b..d46dd73631 100644 --- a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts +++ b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts @@ -18,7 +18,7 @@ const endpoint = () => request(server).post(`/${constants.API.DATABASES}/import` const databaseSchema = Joi.object({ name: Joi.string().allow(null, ''), host: Joi.string().required(), - port: Joi.number().integer().required(), + port: Joi.number().integer().allow(true).required(), db: Joi.number().integer().allow(null, ''), username: Joi.string().allow(null, ''), password: Joi.string().allow(null, ''), @@ -49,7 +49,7 @@ const baseSentinelData = { const importDatabaseFormat1 = { name: baseDatabaseData.name, host: baseDatabaseData.host, - port: baseDatabaseData.port, + port: `${baseDatabaseData.port}`, username: baseDatabaseData.username, auth: baseDatabaseData.password, } @@ -86,7 +86,7 @@ describe('POST /databases/import', () => { responseBody: { statusCode: 400, message: 'No import file provided', - error: 'Bad Request', + error: 'No Database Import File Provided', }, }, { @@ -96,7 +96,7 @@ describe('POST /databases/import', () => { responseBody: { statusCode: 400, message: 'Import file is too big. Maximum 10mb allowed', - error: 'Bad Request', + error: 'Invalid Database Import File', }, }, { @@ -106,7 +106,7 @@ describe('POST /databases/import', () => { responseBody: { statusCode: 400, message: 'Unable to parse filename.json', - error: 'Bad Request', + error: 'Unable To Parse Database Import File', }, }, { @@ -116,7 +116,7 @@ describe('POST /databases/import', () => { responseBody: { statusCode: 400, message: `Unable to parse ${new Array(50).fill(1).join('')}...`, - error: 'Bad Request', + error: 'Unable To Parse Database Import File', }, }, { @@ -126,7 +126,7 @@ describe('POST /databases/import', () => { responseBody: { statusCode: 400, message: `Unable to parse ${new Array(50).fill(1).join('')}...`, - error: 'Bad Request', + error: 'Unable To Parse Database Import File', }, }, ].map(mainCheckFn); From aec6614da5bde5f453a9494963ef0d16a0b7421d Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 16 Nov 2022 13:55:57 +0300 Subject: [PATCH 06/57] #RI-3817 improve port validation + add UTests --- .../api/src/__mocks__/database-import.ts | 37 ++++ redisinsight/api/src/__mocks__/index.ts | 1 + .../database-import.analytics.spec.ts | 96 ++++++++++ .../database-import.service.spec.ts | 170 ++++++++++++++++++ .../database-import.service.ts | 2 +- .../dto/import.database.dto.ts | 6 +- 6 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 redisinsight/api/src/__mocks__/database-import.ts create mode 100644 redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts create mode 100644 redisinsight/api/src/modules/database-import/database-import.service.spec.ts diff --git a/redisinsight/api/src/__mocks__/database-import.ts b/redisinsight/api/src/__mocks__/database-import.ts new file mode 100644 index 0000000000..2ce3ed50a5 --- /dev/null +++ b/redisinsight/api/src/__mocks__/database-import.ts @@ -0,0 +1,37 @@ +import { DatabaseImportResponse } from 'src/modules/database-import/dto/database-import.response'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { mockDatabase } from 'src/__mocks__/databases'; +import { ValidationError } from 'class-validator'; + +export const mockDatabasesToImportArray = new Array(10).fill(mockDatabase); + +export const mockDatabaseImportFile = { + originalname: 'filename.json', + mimetype: 'application/json', + size: 1, + buffer: Buffer.from(JSON.stringify(mockDatabasesToImportArray)), +}; + +export const mockDatabaseImportResponse = Object.assign(new DatabaseImportResponse(), { + total: 10, + success: 7, + errors: [new ValidationError(), new BadRequestException(), new ForbiddenException()], +}); + +export const mockDatabaseImportParseFailedAnalyticsPayload = { + +}; + +export const mockDatabaseImportFailedAnalyticsPayload = { + failed: mockDatabaseImportResponse.errors.length, + errors: ['ValidationError', 'BadRequestException', 'ForbiddenException'], +}; + +export const mockDatabaseImportSucceededAnalyticsPayload = { + succeed: mockDatabaseImportResponse.success, +}; + +export const mockDatabaseImportAnalytics = jest.fn(() => ({ + sendImportResults: jest.fn(), + sendImportFailed: jest.fn(), +})); diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index f378d9dee6..ef2dacddb2 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -16,3 +16,4 @@ export * from './redis'; export * from './server'; export * from './redis-enterprise'; export * from './redis-sentinel'; +export * from './database-import'; diff --git a/redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts b/redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts new file mode 100644 index 0000000000..920cc215f4 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts @@ -0,0 +1,96 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + mockDatabaseImportFailedAnalyticsPayload, + mockDatabaseImportResponse, mockDatabaseImportSucceededAnalyticsPayload, +} from 'src/__mocks__'; +import { TelemetryEvents } from 'src/constants'; +import { DatabaseImportAnalytics } from 'src/modules/database-import/database-import.analytics'; +import { + NoDatabaseImportFileProvidedException, SizeLimitExceededDatabaseImportFileException, + UnableToParseDatabaseImportFileException, +} from 'src/modules/database-import/exceptions'; + +describe('DatabaseImportAnalytics', () => { + let service: DatabaseImportAnalytics; + let sendEventSpy; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + DatabaseImportAnalytics, + ], + }).compile(); + + service = await module.get(DatabaseImportAnalytics); + sendEventSpy = jest.spyOn(service as any, 'sendEvent'); + }); + + describe('sendImportResults', () => { + it('should emit 2 events with success and failed results', () => { + service.sendImportResults(mockDatabaseImportResponse); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 1, + TelemetryEvents.DatabaseImportSucceeded, + mockDatabaseImportSucceededAnalyticsPayload, + ); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 2, + TelemetryEvents.DatabaseImportFailed, + mockDatabaseImportFailedAnalyticsPayload, + ); + }); + }); + + describe('sendImportFailed', () => { + it('should emit 1 event with "Error" cause', () => { + service.sendImportFailed(new Error()); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 1, + TelemetryEvents.DatabaseImportParseFailed, + { + error: 'Error', + }, + ); + }); + it('should emit 1 event with "UnableToParseDatabaseImportFileException" cause', () => { + service.sendImportFailed(new UnableToParseDatabaseImportFileException()); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 1, + TelemetryEvents.DatabaseImportParseFailed, + { + error: 'UnableToParseDatabaseImportFileException', + }, + ); + }); + it('should emit 1 event with "NoDatabaseImportFileProvidedException" cause', () => { + service.sendImportFailed(new NoDatabaseImportFileProvidedException()); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 1, + TelemetryEvents.DatabaseImportParseFailed, + { + error: 'NoDatabaseImportFileProvidedException', + }, + ); + }); + it('should emit 1 event with "SizeLimitExceededDatabaseImportFileException" cause', () => { + service.sendImportFailed(new SizeLimitExceededDatabaseImportFileException()); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 1, + TelemetryEvents.DatabaseImportParseFailed, + { + error: 'SizeLimitExceededDatabaseImportFileException', + }, + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts new file mode 100644 index 0000000000..49de47ea42 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts @@ -0,0 +1,170 @@ +import { pick } from 'lodash'; +import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; +import { + mockDatabase, + mockDatabaseImportAnalytics, + mockDatabaseImportFile, + mockDatabaseImportResponse, + MockType, +} from 'src/__mocks__'; +import { DatabaseRepository } from 'src/modules/database/repositories/database.repository'; +import { DatabaseImportAnalytics } from 'src/modules/database-import/database-import.analytics'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConnectionType } from 'src/modules/database/entities/database.entity'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { ValidationError } from 'class-validator'; +import { + NoDatabaseImportFileProvidedException, SizeLimitExceededDatabaseImportFileException, + UnableToParseDatabaseImportFileException, +} from 'src/modules/database-import/exceptions'; + +describe('DatabaseImportService', () => { + let service: DatabaseImportService; + let databaseRepository: MockType; + let analytics: MockType; + let validatoSpy; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseImportService, + { + provide: DatabaseRepository, + useFactory: jest.fn(() => ({ + create: jest.fn().mockResolvedValue(mockDatabase), + })), + }, + { + provide: DatabaseImportAnalytics, + useFactory: mockDatabaseImportAnalytics, + }, + ], + }).compile(); + + service = await module.get(DatabaseImportService); + databaseRepository = await module.get(DatabaseRepository); + analytics = await module.get(DatabaseImportAnalytics); + validatoSpy = jest.spyOn(service['validator'], 'validateOrReject'); + }); + + describe('importDatabase', () => { + beforeEach(() => { + databaseRepository.create.mockRejectedValueOnce(new BadRequestException()); + databaseRepository.create.mockRejectedValueOnce(new ForbiddenException()); + validatoSpy.mockRejectedValueOnce([new ValidationError()]); + }); + + it('should import databases from json', async () => { + const response = await service.import(mockDatabaseImportFile); + + expect(response).toEqual({ + ...mockDatabaseImportResponse, + errors: undefined, // errors omitted from response + }); + expect(analytics.sendImportResults).toHaveBeenCalledWith(mockDatabaseImportResponse); + }); + + it('should import databases from base64', async () => { + const response = await service.import({ + ...mockDatabaseImportFile, + mimetype: 'binary/octet-stream', + buffer: Buffer.from(mockDatabaseImportFile.buffer.toString('base64')), + }); + + expect(response).toEqual({ + ...mockDatabaseImportResponse, + errors: undefined, // errors omitted from response + }); + expect(analytics.sendImportResults).toHaveBeenCalledWith(mockDatabaseImportResponse); + }); + + it('should fail due to file was not provided', async () => { + try { + await service.import(undefined); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NoDatabaseImportFileProvidedException); + expect(e.message).toEqual('No import file provided'); + expect(analytics.sendImportFailed) + .toHaveBeenCalledWith(new NoDatabaseImportFileProvidedException('No import file provided')); + } + }); + + it('should fail due to file exceeded size limitations', async () => { + try { + await service.import({ + ...mockDatabaseImportFile, + size: 10 * 1024 * 1024 + 1, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(SizeLimitExceededDatabaseImportFileException); + expect(e.message).toEqual('Import file is too big. Maximum 10mb allowed'); + } + }); + + it('should fail due to incorrect json', async () => { + try { + await service.import({ + ...mockDatabaseImportFile, + buffer: Buffer.from([0, 21]), + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(UnableToParseDatabaseImportFileException); + expect(e.message).toEqual(`Unable to parse ${mockDatabaseImportFile.originalname}`); + } + }); + + it('should faile due to incorrect base64 + truncate filename', async () => { + try { + await service.import({ + ...mockDatabaseImportFile, + originalname: (new Array(1_000).fill(1)).join(''), + mimetype: 'binary/octet-stream', + buffer: Buffer.from([0, 21]), + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(UnableToParseDatabaseImportFileException); + expect(e.message).toEqual(`Unable to parse ${(new Array(50).fill(1)).join('')}...`); + } + }); + }); + + describe('createDatabase', () => { + it('should create standalone database', async () => { + await service['createDatabase']({ + ...mockDatabase, + }); + + expect(databaseRepository.create).toHaveBeenCalledWith({ + ...pick(mockDatabase, ['host', 'port', 'name', 'connectionType']), + }); + }); + it('should create standalone with created name', async () => { + await service['createDatabase']({ + ...mockDatabase, + name: undefined, + }); + + expect(databaseRepository.create).toHaveBeenCalledWith({ + ...pick(mockDatabase, ['host', 'port', 'name', 'connectionType']), + name: `${mockDatabase.host}:${mockDatabase.port}`, + }); + }); + it('should create cluster database', async () => { + await service['createDatabase']({ + ...mockDatabase, + cluster: true, + }); + + expect(databaseRepository.create).toHaveBeenCalledWith({ + ...pick(mockDatabase, ['host', 'port', 'name']), + connectionType: ConnectionType.CLUSTER, + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index d83b14efb7..8fdc6c9fdd 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -99,7 +99,7 @@ export class DatabaseImportService { * @param item * @private */ - private async createDatabase(item: any[]): Promise { + private async createDatabase(item: any): Promise { const data: any = {}; this.fieldsMapSchema.forEach(([field, paths]) => { diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index 7fd5f757fd..92bd244a08 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -1,7 +1,9 @@ import { PickType } from '@nestjs/swagger'; import { Database } from 'src/modules/database/models/database'; import { Expose, Type } from 'class-transformer'; -import { IsInt, IsNotEmpty } from 'class-validator'; +import { + IsInt, IsNotEmpty, Max, Min, +} from 'class-validator'; export class ImportDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', @@ -11,5 +13,7 @@ export class ImportDatabaseDto extends PickType(Database, [ @IsNotEmpty() @IsInt({ always: true }) @Type(() => Number) + @Min(0) + @Max(65536) port: number; } From 9661ccb43454457dfa7e09faf85d88969d4dc7fa Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 16 Nov 2022 16:11:38 +0300 Subject: [PATCH 07/57] fix max port validation --- .../api/src/modules/database-import/dto/import.database.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index 92bd244a08..de696bcafc 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -14,6 +14,6 @@ export class ImportDatabaseDto extends PickType(Database, [ @IsInt({ always: true }) @Type(() => Number) @Min(0) - @Max(65536) + @Max(65535) port: number; } From 775325a109fb82eb5f768bb5a455ba5bed512572 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 16 Nov 2022 20:18:56 +0100 Subject: [PATCH 08/57] add tests for databases import --- .../InstanceForm/InstanceForm.tsx | 2 +- tests/e2e/common-actions/databases-actions.ts | 31 ++++ tests/e2e/helpers/api/api-database.ts | 14 ++ .../pageObjects/add-redis-database-page.ts | 2 + .../pageObjects/my-redis-databases-page.ts | 11 +- tests/e2e/test-data/ardm-valid.ano | 1 + tests/e2e/test-data/racompass-invalid.json | 171 ++++++++++++++++++ tests/e2e/test-data/racompass-valid.json | 171 ++++++++++++++++++ tests/e2e/test-data/rdm-valid.json | 62 +++++++ .../database/import-databases.e2e.ts | 99 ++++++++++ 10 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/common-actions/databases-actions.ts create mode 100644 tests/e2e/test-data/ardm-valid.ano create mode 100644 tests/e2e/test-data/racompass-invalid.json create mode 100644 tests/e2e/test-data/racompass-valid.json create mode 100644 tests/e2e/test-data/rdm-valid.json create mode 100644 tests/e2e/tests/critical-path/database/import-databases.e2e.ts diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx index c9af186d30..42e85eef0a 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx @@ -448,7 +448,7 @@ const AddStandaloneForm = (props: Props) => { label={( Connection Type: - + {capitalize(connectionType)} diff --git a/tests/e2e/common-actions/databases-actions.ts b/tests/e2e/common-actions/databases-actions.ts new file mode 100644 index 0000000000..129b24dbf7 --- /dev/null +++ b/tests/e2e/common-actions/databases-actions.ts @@ -0,0 +1,31 @@ +import { t } from 'testcafe'; +import { MyRedisDatabasePage } from '../pageObjects'; + +const myRedisDatabasePage = new MyRedisDatabasePage(); + +export class DatabasesActions { + /** + * Verify that databases are displayed + * @param databases The list of databases to verify + */ + async verifyDatabasesDisplayed(databases: string[]): Promise { + for (const db in databases) { + const databaseName = myRedisDatabasePage.dbNameList.withText(db); + await t.expect(databaseName.exists).ok(`"${db}" database doesn't exist`); + } + } + + /** + * Import database using file + * @param pathToFile The path to file for import + * @param databaseType The database type + */ + async importDatabase(pathToFile: string, databaseType = ''): Promise { + await t + .click(myRedisDatabasePage.importDatabasesBtn) + .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [pathToFile]) + .click(myRedisDatabasePage.submitImportBtn) + .expect(myRedisDatabasePage.successImportMessage.exists).ok(`Successfully added ${databaseType} databases message not displayed`); + } + +} diff --git a/tests/e2e/helpers/api/api-database.ts b/tests/e2e/helpers/api/api-database.ts index 4f87be5e4c..8cd2693be8 100644 --- a/tests/e2e/helpers/api/api-database.ts +++ b/tests/e2e/helpers/api/api-database.ts @@ -153,6 +153,20 @@ export async function deleteStandaloneDatabaseApi(databaseParameters: AddNewData .expect(200); } +/** + * Delete Standalone databases using their names through api + * @param databaseNames Databases names + */ +export async function deleteStandaloneDatabasesByNamesApi(databaseNames: string[]): Promise { + databaseNames.forEach(async databaseName => { + const databaseId = await getDatabaseByName(databaseName); + await request(endpoint).delete('/databases') + .send({ 'ids': [`${databaseId}`] }) + .set('Accept', 'application/json') + .expect(200); + }); +} + /** * Delete database from OSS Cluster through api * @param databaseParameters The database parameters diff --git a/tests/e2e/pageObjects/add-redis-database-page.ts b/tests/e2e/pageObjects/add-redis-database-page.ts index ff93abfd52..22fcad871b 100644 --- a/tests/e2e/pageObjects/add-redis-database-page.ts +++ b/tests/e2e/pageObjects/add-redis-database-page.ts @@ -28,6 +28,7 @@ export class AddRedisDatabasePage { sentinelDatabaseNavigation = Selector('[data-testid=database-nav-group]'); cloneSentinelDatabaseNavigation = Selector('[data-testid=database-nav-group-clone]'); cancelButton = Selector('[data-testid=btn-cancel]'); + showPasswordBtn = Selector('[aria-label^="Show password"]'); //TEXT INPUTS (also referred to as 'Text fields') hostInput = Selector('[data-testid=host]'); portInput = Selector('[data-testid=port]'); @@ -42,6 +43,7 @@ export class AddRedisDatabasePage { databaseIndexMessage = Selector('[data-testid=db-index-message]'); primaryGroupNameInput = Selector('[data-testid=primary-group]'); masterGroupPassword = Selector('[data-testid=sentinel-master-password]'); + connectionType = Selector('[data-testid=connection-type]'); //Links buildFromSource = Selector('a').withExactText('Build from source'); buildFromDocker = Selector('a').withExactText('Docker'); diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index e749a9d795..89a78e35da 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -15,7 +15,7 @@ export class MyRedisDatabasePage { githubButton = Selector('[data-testid=github-repo-icon]'); browserButton = Selector('[data-testid=browser-page-btn]'); pubSubButton = Selector('[data-testid=pub-sub-page-btn]'); - myRedisDBButton = Selector('[data-test-subj=home-page-btn]'); + myRedisDBButton = Selector('[data-test-subj=home-page-btn]', { timeout: 1000 }); deleteDatabaseButton = Selector('[data-testid^=delete-instance-]'); confirmDeleteButton = Selector('[data-testid^=delete-instance-]').withExactText('Remove'); toastCloseButton = Selector('[data-test-subj=toastCloseButton]'); @@ -30,6 +30,10 @@ export class MyRedisDatabasePage { sortByHostAndPort = Selector('span').withAttribute('title', 'Host:Port'); sortByConnectionType = Selector('span').withAttribute('title', 'Connection Type'); sortByLastConnection = Selector('span').withAttribute('title', 'Last connection'); + importDatabasesBtn = Selector('[data-testid=import-dbs-btn]'); + submitImportBtn = Selector('[data-testid=submit-btn]'); + closeDialogBtn = Selector('[aria-label="Closes this modal window"]'); + okDialogBtn = Selector('[data-testid=ok-btn]'); //CHECKBOXES selectAllCheckbox = Selector('[data-test-subj=checkboxSelectAll]'); //ICONS @@ -46,6 +50,7 @@ export class MyRedisDatabasePage { //TEXT INPUTS (also referred to as 'Text fields') aliasInput = Selector('[data-testid=alias-input]'); searchInput = Selector('[data-testid=search-database-list]'); + importDatabaseInput = Selector('[data-testid=import-databases-input-file]'); //TEXT ELEMENTS moduleTooltip = Selector('.euiToolTipPopover'); moduleQuantifier = Selector('[data-testid=_module]'); @@ -55,6 +60,10 @@ export class MyRedisDatabasePage { hostPort = Selector('[data-testid=host-port]'); noResultsFoundMessage = Selector('div').withExactText('No results found'); noResultsFoundText = Selector('div').withExactText('No databases matched your search. Try reducing the criteria.'); + failedImportMessage = Selector('[data-testid=result-failed]'); + successImportMessage = Selector('[data-testid=result-success]'); + // DIALOG + importDbDialog = Selector('[data-testid=import-dbs-dialog]'); /** * Click on the database by name diff --git a/tests/e2e/test-data/ardm-valid.ano b/tests/e2e/test-data/ardm-valid.ano new file mode 100644 index 0000000000..7c557d1989 --- /dev/null +++ b/tests/e2e/test-data/ardm-valid.ano @@ -0,0 +1 @@ +W3siaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiI2Mzc5IiwiYXV0aCI6InBhc3MiLCJ1c2VybmFtZSI6InVzZXJuYW1lVGVzdCIsIm5hbWUiOiJhcmRtV2l0aFBhc3NBbmRVc2VybmFtZSIsInNlcGFyYXRvciI6IjoiLCJjbHVzdGVyIjpmYWxzZSwia2V5IjoiMTY1MDM3MzUyNTY1MV9naHFwciIsIm9yZGVyIjowfSx7Imhvc3QiOiJhcmRtTm9OYW1lIiwicG9ydCI6IjEyMDAxIiwiYXV0aCI6IiIsInVzZXJuYW1lIjoiIiwic2VwYXJhdG9yIjoiOiIsImNsdXN0ZXIiOmZhbHNlLCJrZXkiOiIxNjUwODk3NjIxNzU0X2I1bjB2Iiwib3JkZXIiOjF9XQ== \ No newline at end of file diff --git a/tests/e2e/test-data/racompass-invalid.json b/tests/e2e/test-data/racompass-invalid.json new file mode 100644 index 0000000000..aab8b3e325 --- /dev/null +++ b/tests/e2e/test-data/racompass-invalid.json @@ -0,0 +1,171 @@ +[ + { + "srvRecords": [ + { + "priority": null, + "weight": null, + "port": null, + "name": null + } + ], + "useSRVRecords": false, + "natMaps": [ + { + "privateHost": null, + "privatePort": null, + "publicHost": null, + "publicPort": null + } + ], + "enableNatMaps": false, + "clusterOptions": { + "slotsRefreshInterval": 5000, + "slotsRefreshTimeout": 1000, + "retryDelayOnTryAgain": 100, + "retryDelayOnClusterDown": 100, + "retryDelayOnFailover": 100, + "retryDelayOnMoved": 0, + "maxRedirections": 16, + "dnsLookup": false, + "scaleReads": "master", + "startupNodes": [ + { + "host": null, + "port": null + } + ] + }, + "enableStartupNodes": false, + "sentinelOptions": { + "tls": { + "key": null, + "keyBookmark": null, + "ca": null, + "caBookmark": null, + "cert": null, + "certBookmark": null + }, + "preferredSlaves": [ + { + "host": null, + "port": null, + "priority": null + } + ], + "role": null, + "sentinelPassword": null, + "name": null + }, + "enablePreferredSlaves": false, + "sshKeyPassphrase": null, + "sshKeyFileBookmark": null, + "sshKeyFile": null, + "sshUser": null, + "sshPort": null, + "sshHost": null, + "ssh": false, + "caCertBookmark": null, + "caCert": null, + "certificateBookmark": null, + "certificate": null, + "keyFileBookmark": null, + "keyFile": null, + "ssl": false, + "default": false, + "star": false, + "totalDb": 16, + "db": 0, + "password": "", + "color": "#4B5563", + "host": "localhost", + "keyPrefix": null, + "type": "standalone", + "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9" + }, + { + "srvRecords": [ + { + "priority": null, + "weight": null, + "port": null, + "name": null + } + ], + "useSRVRecords": false, + "natMaps": [ + { + "privateHost": null, + "privatePort": null, + "publicHost": null, + "publicPort": null + } + ], + "enableNatMaps": false, + "clusterOptions": { + "slotsRefreshInterval": 5000, + "slotsRefreshTimeout": 1000, + "retryDelayOnTryAgain": 100, + "retryDelayOnClusterDown": 100, + "retryDelayOnFailover": 100, + "retryDelayOnMoved": 0, + "maxRedirections": 16, + "dnsLookup": false, + "scaleReads": "master", + "startupNodes": [ + { + "host": null, + "port": null + } + ] + }, + "enableStartupNodes": false, + "sentinelOptions": { + "tls": { + "key": null, + "keyBookmark": null, + "ca": null, + "caBookmark": null, + "cert": null, + "certBookmark": null + }, + "preferredSlaves": [ + { + "host": null, + "port": null, + "priority": null + } + ], + "role": null, + "sentinelPassword": null, + "name": null + }, + "enablePreferredSlaves": false, + "sshKeyPassphrase": null, + "sshKeyFileBookmark": null, + "sshKeyFile": null, + "sshUser": null, + "sshPort": null, + "sshHost": null, + "ssh": false, + "caCertBookmark": null, + "caCert": null, + "certificateBookmark": null, + "certificate": null, + "keyFileBookmark": null, + "keyFile": null, + "ssl": false, + "default": false, + "star": false, + "totalDb": 16, + "db": 1, + "password": "vfsd", + "color": "#4B5563", + "port": 1111, + "keyPrefix": null, + "type": "standalone", + "connectionName": "vd", + "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9", + "cluster": true, + "name": "vd long host" + } +] \ No newline at end of file diff --git a/tests/e2e/test-data/racompass-valid.json b/tests/e2e/test-data/racompass-valid.json new file mode 100644 index 0000000000..dc4e32dcf5 --- /dev/null +++ b/tests/e2e/test-data/racompass-valid.json @@ -0,0 +1,171 @@ +[ + { + "srvRecords": [ + { + "priority": null, + "weight": null, + "port": null, + "name": null + } + ], + "useSRVRecords": false, + "natMaps": [ + { + "privateHost": null, + "privatePort": null, + "publicHost": null, + "publicPort": null + } + ], + "enableNatMaps": false, + "clusterOptions": { + "slotsRefreshInterval": 5000, + "slotsRefreshTimeout": 1000, + "retryDelayOnTryAgain": 100, + "retryDelayOnClusterDown": 100, + "retryDelayOnFailover": 100, + "retryDelayOnMoved": 0, + "maxRedirections": 16, + "dnsLookup": false, + "scaleReads": "master", + "startupNodes": [ + { + "host": null, + "port": null + } + ] + }, + "enableStartupNodes": false, + "sentinelOptions": { + "tls": { + "key": null, + "keyBookmark": null, + "ca": null, + "caBookmark": null, + "cert": null, + "certBookmark": null + }, + "preferredSlaves": [ + { + "host": null, + "port": null, + "priority": null + } + ], + "role": null, + "sentinelPassword": null, + "name": null + }, + "enablePreferredSlaves": false, + "sshKeyPassphrase": null, + "sshKeyFileBookmark": null, + "sshKeyFile": null, + "sshUser": null, + "sshPort": null, + "sshHost": null, + "ssh": false, + "caCertBookmark": null, + "caCert": null, + "certificateBookmark": null, + "certificate": null, + "keyFileBookmark": null, + "keyFile": null, + "ssl": false, + "default": false, + "star": false, + "totalDb": 16, + "db": 1, + "password": "", + "color": "#4B5563", + "port": 8100, + "host": "racompassDbWithIndex", + "keyPrefix": null, + "type": "standalone", + "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9" + }, + { + "srvRecords": [ + { + "priority": null, + "weight": null, + "port": null, + "name": null + } + ], + "useSRVRecords": false, + "natMaps": [ + { + "privateHost": null, + "privatePort": null, + "publicHost": null, + "publicPort": null + } + ], + "enableNatMaps": false, + "clusterOptions": { + "slotsRefreshInterval": 5000, + "slotsRefreshTimeout": 1000, + "retryDelayOnTryAgain": 100, + "retryDelayOnClusterDown": 100, + "retryDelayOnFailover": 100, + "retryDelayOnMoved": 0, + "maxRedirections": 16, + "dnsLookup": false, + "scaleReads": "master", + "startupNodes": [ + { + "host": null, + "port": null + } + ] + }, + "enableStartupNodes": false, + "sentinelOptions": { + "tls": { + "key": null, + "keyBookmark": null, + "ca": null, + "caBookmark": null, + "cert": null, + "certBookmark": null + }, + "preferredSlaves": [ + { + "host": null, + "port": null, + "priority": null + } + ], + "role": null, + "sentinelPassword": null, + "name": null + }, + "enablePreferredSlaves": false, + "sshKeyPassphrase": null, + "sshKeyFileBookmark": null, + "sshKeyFile": null, + "sshUser": null, + "sshPort": null, + "sshHost": null, + "ssh": false, + "caCertBookmark": null, + "caCert": null, + "certificateBookmark": null, + "certificate": null, + "keyFileBookmark": null, + "keyFile": null, + "ssl": false, + "default": false, + "star": false, + "totalDb": 16, + "db": 0, + "password": "vfsd", + "color": "#4B5563", + "host": "localhost", + "port": 1111, + "keyPrefix": null, + "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9", + "cluster": true, + "name": "racompassCluster" + } +] \ No newline at end of file diff --git a/tests/e2e/test-data/rdm-valid.json b/tests/e2e/test-data/rdm-valid.json new file mode 100644 index 0000000000..845fe507e7 --- /dev/null +++ b/tests/e2e/test-data/rdm-valid.json @@ -0,0 +1,62 @@ +[ + { + "password": "new", + "filter_history": { + "*": 1, + "stream1": 1 + }, + "host": "localhost", + "name": "vfd das jashd ashdkjh kjhasfh hfjks dahfjk shdk fhskjad hfkj sdhkj fashk dhfk sahkj fhsak dhfskja hsd kfjh dsakj fhsdjk fhskdjal fhksjd hfkjsd hfkjs hakdjfh sjkdah fjksdh fjksdh jkfh ksdh fsdkjhfksjhfkhsdkf hksjdhf kjsdhf skdhf sdf sdfsda fsdfsd fsd fsdf sd f sdf sd f sd fsd f sd f sd fsd f sad fs df sd f s dsa fsdf vfd das jashd ashdkjh", + "ssh_port": 22, + "timeout_connect": 60000, + "timeout_execute": 60000, + "cluster": false, + "invalid_field": "inv", + "db": 0 + }, + { + "host": "rdmWithUsernameAndPass1", + "port": "1561", + "cluster": true, + "username": "rdmUsername", + "auth": "rdmAuth" + }, + { + "auth": "", + "host": "172.30.100.151", + "name": "oss cluster", + "ssh_port": 22, + "cluster": true, + "timeout_connect": 60000, + "timeout_execute": 60000 + }, + { + "auth": "longname database >500 symbols invalid", + "host": "172.30.100.181", + "port": 1000, + "keys_pattern": "*", + "name": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla rutrum nec libero aliquet ultricies. Donec ut blandit dui, ac hendrerit risus. Vestibulum sodales sed risus ac auctor. Integer quis justo vel leo gravida volutpat quis et risus. Nam vestibulum fermentum eros, in ullamcorper nulla laoreet quis. Vestibulum ut arcu nec turpis elementum malesuada. Maecenas congue felis nec posuere ullamcorper. Phasellus auctor leo sit amet ligula scelerisque, quis elementum ligula auctor. Quisque id leo r", + "namespace_separator": ":", + "ssh_port": 22, + "ssl": true, + "ssl_ca_cert_path": "E:/Redis/redisinsight-envs/cluster-tls-client/certs/ca.crt", + "ssl_local_cert_path": "E:/Redis/redisinsight-envs/cluster-tls-client/certs/client.crt", + "ssl_private_key_path": "E:/Redis/redisinsight-envs/cluster-tls-client/certs/client.key", + "timeout_connect": 60000, + "timeout_execute": 60000 + }, + { + "auth": "pass", + "host": "localhost", + "name": "plain-certs+pass", + "port": 65537, + "ssh_port": 22, + "ssl_local_cert_path": "C:/Users/enaboko/Downloads/certificates", + "timeout_connect": 60000, + "timeout_execute": 60000 + }, + { + "host": "rdmOnlyHostPortDB2", + "port": 6379 + } +] diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts new file mode 100644 index 0000000000..db100c0727 --- /dev/null +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -0,0 +1,99 @@ +import { rte } from '../../../helpers/constants'; +import { AddRedisDatabasePage, BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; +import { commonUrl } from '../../../helpers/conf'; +import { acceptLicenseTerms, clickOnEditDatabaseByName } from '../../../helpers/database'; +import { deleteStandaloneDatabasesByNamesApi } from '../../../helpers/api/api-database'; +import { DatabasesActions } from '../../../common-actions/databases-actions'; + +const browserPage = new BrowserPage(); +const myRedisDatabasePage = new MyRedisDatabasePage(); +const databasesActions = new DatabasesActions(); +const addRedisDatabasePage = new AddRedisDatabasePage(); + +const invalidJsonPath = '../../../test-data/racompass-invalid.json'; +const rdmData = { + type: 'rdm', + path: '../../../test-data/rdm-valid.json', + dbNames: ['rdmWithUsernameAndPass1:1561', 'rdmOnlyHostPortDB2:6379'], + userName: 'rdmUsername', + password: 'rdmAuth', + connectionType: 'Cluster' +}; +const dbData = [ + { + type: 'racompass', + path: '../../../test-data/racompass-valid.json', + dbNames: ['racompassCluster', 'racompassDbWithIndex:8100 [1]'] + }, + { + type: 'ardm', + path: '../../../test-data/ardm-valid.ano', + dbNames: ['ardmNoName:12001', 'ardmWithPassAndUsername'] + } +]; +const databases = [ + rdmData.dbNames[0], + rdmData.dbNames[1], + dbData[0].dbNames[0], + dbData[0].dbNames[1].split(' ')[0], + dbData[1].dbNames[0], + dbData[1].dbNames[1] +]; + +fixture `Import databases` + .meta({ type: 'critical_path', rte: rte.standalone }) + .page(commonUrl); +test + .before(async() => { + await acceptLicenseTerms(); + }) + .after(async() => { + // Delete databases + deleteStandaloneDatabasesByNamesApi(databases); + })('Connection import from JSON', async t => { + const tooltipText = 'Import Database Connections'; + const partialImportedMsg = 'Successfully added 2 of 6 database connections'; + + // Verify that user can see the “Import Database Connections” tooltip + await t.hover(myRedisDatabasePage.importDatabasesBtn); + await t.hover(myRedisDatabasePage.importDatabasesBtn); + await t.expect(browserPage.tooltip.innerText).contains(tooltipText, 'The tooltip message not displayed/correct'); + + // Verify that Import dialogue is not closed when clicking any area outside the box + await t.click(myRedisDatabasePage.importDatabasesBtn); + await t.expect(myRedisDatabasePage.importDbDialog.exists).ok('Import Database Connections dialog not opened'); + await t.click(myRedisDatabasePage.myRedisDBButton); + await t.expect(myRedisDatabasePage.importDbDialog.exists).ok('Import Database Connections dialog not displayed'); + + // Verify that user see the message when parse error appears + await t + .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [invalidJsonPath]) + .click(myRedisDatabasePage.submitImportBtn) + .expect(myRedisDatabasePage.failedImportMessage.exists).ok('Failed to add database message not displayed'); + + // Verify that success message is displayed + await t.click(myRedisDatabasePage.closeDialogBtn); + await databasesActions.importDatabase(rdmData.path); + await t.expect(myRedisDatabasePage.successImportMessage.textContent).contains(partialImportedMsg, 'Successfully added databases number not correct'); + + // Verify that list of databases is reloaded when database added + await t.click(myRedisDatabasePage.okDialogBtn); + await databasesActions.verifyDatabasesDisplayed(rdmData.dbNames); + + // Verify that user can import database with all data + await clickOnEditDatabaseByName(rdmData.dbNames[0]); + // Verify username imported + await t.expect(addRedisDatabasePage.usernameInput.value).eql(rdmData.userName); + // Verify password imported + await t.click(addRedisDatabasePage.showPasswordBtn); + await t.expect(addRedisDatabasePage.passwordInput.value).eql(rdmData.password); + // Verify cluster connection type imported + await t.expect(addRedisDatabasePage.connectionType.textContent).eql(rdmData.connectionType); + + // Verify that user can import files from Racompass, ARDM, RDM + for (const db of dbData) { + await databasesActions.importDatabase(db.path, db.type); + await t.click(myRedisDatabasePage.okDialogBtn); + await databasesActions.verifyDatabasesDisplayed(db.dbNames); + } + }); From a984c4d016c502b908e4bc3dba690e5fed2946bb Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 16 Nov 2022 21:40:41 +0100 Subject: [PATCH 09/57] Revert "add tests for databases import" This reverts commit 775325a109fb82eb5f768bb5a455ba5bed512572. --- .../InstanceForm/InstanceForm.tsx | 2 +- tests/e2e/common-actions/databases-actions.ts | 31 ---- tests/e2e/helpers/api/api-database.ts | 14 -- .../pageObjects/add-redis-database-page.ts | 2 - .../pageObjects/my-redis-databases-page.ts | 11 +- tests/e2e/test-data/ardm-valid.ano | 1 - tests/e2e/test-data/racompass-invalid.json | 171 ------------------ tests/e2e/test-data/racompass-valid.json | 171 ------------------ tests/e2e/test-data/rdm-valid.json | 62 ------- .../database/import-databases.e2e.ts | 99 ---------- 10 files changed, 2 insertions(+), 562 deletions(-) delete mode 100644 tests/e2e/common-actions/databases-actions.ts delete mode 100644 tests/e2e/test-data/ardm-valid.ano delete mode 100644 tests/e2e/test-data/racompass-invalid.json delete mode 100644 tests/e2e/test-data/racompass-valid.json delete mode 100644 tests/e2e/test-data/rdm-valid.json delete mode 100644 tests/e2e/tests/critical-path/database/import-databases.e2e.ts diff --git a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx index 42e85eef0a..c9af186d30 100644 --- a/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx +++ b/redisinsight/ui/src/pages/home/components/AddInstanceForm/InstanceForm/InstanceForm.tsx @@ -448,7 +448,7 @@ const AddStandaloneForm = (props: Props) => { label={( Connection Type: - + {capitalize(connectionType)} diff --git a/tests/e2e/common-actions/databases-actions.ts b/tests/e2e/common-actions/databases-actions.ts deleted file mode 100644 index 129b24dbf7..0000000000 --- a/tests/e2e/common-actions/databases-actions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { t } from 'testcafe'; -import { MyRedisDatabasePage } from '../pageObjects'; - -const myRedisDatabasePage = new MyRedisDatabasePage(); - -export class DatabasesActions { - /** - * Verify that databases are displayed - * @param databases The list of databases to verify - */ - async verifyDatabasesDisplayed(databases: string[]): Promise { - for (const db in databases) { - const databaseName = myRedisDatabasePage.dbNameList.withText(db); - await t.expect(databaseName.exists).ok(`"${db}" database doesn't exist`); - } - } - - /** - * Import database using file - * @param pathToFile The path to file for import - * @param databaseType The database type - */ - async importDatabase(pathToFile: string, databaseType = ''): Promise { - await t - .click(myRedisDatabasePage.importDatabasesBtn) - .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [pathToFile]) - .click(myRedisDatabasePage.submitImportBtn) - .expect(myRedisDatabasePage.successImportMessage.exists).ok(`Successfully added ${databaseType} databases message not displayed`); - } - -} diff --git a/tests/e2e/helpers/api/api-database.ts b/tests/e2e/helpers/api/api-database.ts index 8cd2693be8..4f87be5e4c 100644 --- a/tests/e2e/helpers/api/api-database.ts +++ b/tests/e2e/helpers/api/api-database.ts @@ -153,20 +153,6 @@ export async function deleteStandaloneDatabaseApi(databaseParameters: AddNewData .expect(200); } -/** - * Delete Standalone databases using their names through api - * @param databaseNames Databases names - */ -export async function deleteStandaloneDatabasesByNamesApi(databaseNames: string[]): Promise { - databaseNames.forEach(async databaseName => { - const databaseId = await getDatabaseByName(databaseName); - await request(endpoint).delete('/databases') - .send({ 'ids': [`${databaseId}`] }) - .set('Accept', 'application/json') - .expect(200); - }); -} - /** * Delete database from OSS Cluster through api * @param databaseParameters The database parameters diff --git a/tests/e2e/pageObjects/add-redis-database-page.ts b/tests/e2e/pageObjects/add-redis-database-page.ts index 22fcad871b..ff93abfd52 100644 --- a/tests/e2e/pageObjects/add-redis-database-page.ts +++ b/tests/e2e/pageObjects/add-redis-database-page.ts @@ -28,7 +28,6 @@ export class AddRedisDatabasePage { sentinelDatabaseNavigation = Selector('[data-testid=database-nav-group]'); cloneSentinelDatabaseNavigation = Selector('[data-testid=database-nav-group-clone]'); cancelButton = Selector('[data-testid=btn-cancel]'); - showPasswordBtn = Selector('[aria-label^="Show password"]'); //TEXT INPUTS (also referred to as 'Text fields') hostInput = Selector('[data-testid=host]'); portInput = Selector('[data-testid=port]'); @@ -43,7 +42,6 @@ export class AddRedisDatabasePage { databaseIndexMessage = Selector('[data-testid=db-index-message]'); primaryGroupNameInput = Selector('[data-testid=primary-group]'); masterGroupPassword = Selector('[data-testid=sentinel-master-password]'); - connectionType = Selector('[data-testid=connection-type]'); //Links buildFromSource = Selector('a').withExactText('Build from source'); buildFromDocker = Selector('a').withExactText('Docker'); diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index 89a78e35da..e749a9d795 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -15,7 +15,7 @@ export class MyRedisDatabasePage { githubButton = Selector('[data-testid=github-repo-icon]'); browserButton = Selector('[data-testid=browser-page-btn]'); pubSubButton = Selector('[data-testid=pub-sub-page-btn]'); - myRedisDBButton = Selector('[data-test-subj=home-page-btn]', { timeout: 1000 }); + myRedisDBButton = Selector('[data-test-subj=home-page-btn]'); deleteDatabaseButton = Selector('[data-testid^=delete-instance-]'); confirmDeleteButton = Selector('[data-testid^=delete-instance-]').withExactText('Remove'); toastCloseButton = Selector('[data-test-subj=toastCloseButton]'); @@ -30,10 +30,6 @@ export class MyRedisDatabasePage { sortByHostAndPort = Selector('span').withAttribute('title', 'Host:Port'); sortByConnectionType = Selector('span').withAttribute('title', 'Connection Type'); sortByLastConnection = Selector('span').withAttribute('title', 'Last connection'); - importDatabasesBtn = Selector('[data-testid=import-dbs-btn]'); - submitImportBtn = Selector('[data-testid=submit-btn]'); - closeDialogBtn = Selector('[aria-label="Closes this modal window"]'); - okDialogBtn = Selector('[data-testid=ok-btn]'); //CHECKBOXES selectAllCheckbox = Selector('[data-test-subj=checkboxSelectAll]'); //ICONS @@ -50,7 +46,6 @@ export class MyRedisDatabasePage { //TEXT INPUTS (also referred to as 'Text fields') aliasInput = Selector('[data-testid=alias-input]'); searchInput = Selector('[data-testid=search-database-list]'); - importDatabaseInput = Selector('[data-testid=import-databases-input-file]'); //TEXT ELEMENTS moduleTooltip = Selector('.euiToolTipPopover'); moduleQuantifier = Selector('[data-testid=_module]'); @@ -60,10 +55,6 @@ export class MyRedisDatabasePage { hostPort = Selector('[data-testid=host-port]'); noResultsFoundMessage = Selector('div').withExactText('No results found'); noResultsFoundText = Selector('div').withExactText('No databases matched your search. Try reducing the criteria.'); - failedImportMessage = Selector('[data-testid=result-failed]'); - successImportMessage = Selector('[data-testid=result-success]'); - // DIALOG - importDbDialog = Selector('[data-testid=import-dbs-dialog]'); /** * Click on the database by name diff --git a/tests/e2e/test-data/ardm-valid.ano b/tests/e2e/test-data/ardm-valid.ano deleted file mode 100644 index 7c557d1989..0000000000 --- a/tests/e2e/test-data/ardm-valid.ano +++ /dev/null @@ -1 +0,0 @@ -W3siaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiI2Mzc5IiwiYXV0aCI6InBhc3MiLCJ1c2VybmFtZSI6InVzZXJuYW1lVGVzdCIsIm5hbWUiOiJhcmRtV2l0aFBhc3NBbmRVc2VybmFtZSIsInNlcGFyYXRvciI6IjoiLCJjbHVzdGVyIjpmYWxzZSwia2V5IjoiMTY1MDM3MzUyNTY1MV9naHFwciIsIm9yZGVyIjowfSx7Imhvc3QiOiJhcmRtTm9OYW1lIiwicG9ydCI6IjEyMDAxIiwiYXV0aCI6IiIsInVzZXJuYW1lIjoiIiwic2VwYXJhdG9yIjoiOiIsImNsdXN0ZXIiOmZhbHNlLCJrZXkiOiIxNjUwODk3NjIxNzU0X2I1bjB2Iiwib3JkZXIiOjF9XQ== \ No newline at end of file diff --git a/tests/e2e/test-data/racompass-invalid.json b/tests/e2e/test-data/racompass-invalid.json deleted file mode 100644 index aab8b3e325..0000000000 --- a/tests/e2e/test-data/racompass-invalid.json +++ /dev/null @@ -1,171 +0,0 @@ -[ - { - "srvRecords": [ - { - "priority": null, - "weight": null, - "port": null, - "name": null - } - ], - "useSRVRecords": false, - "natMaps": [ - { - "privateHost": null, - "privatePort": null, - "publicHost": null, - "publicPort": null - } - ], - "enableNatMaps": false, - "clusterOptions": { - "slotsRefreshInterval": 5000, - "slotsRefreshTimeout": 1000, - "retryDelayOnTryAgain": 100, - "retryDelayOnClusterDown": 100, - "retryDelayOnFailover": 100, - "retryDelayOnMoved": 0, - "maxRedirections": 16, - "dnsLookup": false, - "scaleReads": "master", - "startupNodes": [ - { - "host": null, - "port": null - } - ] - }, - "enableStartupNodes": false, - "sentinelOptions": { - "tls": { - "key": null, - "keyBookmark": null, - "ca": null, - "caBookmark": null, - "cert": null, - "certBookmark": null - }, - "preferredSlaves": [ - { - "host": null, - "port": null, - "priority": null - } - ], - "role": null, - "sentinelPassword": null, - "name": null - }, - "enablePreferredSlaves": false, - "sshKeyPassphrase": null, - "sshKeyFileBookmark": null, - "sshKeyFile": null, - "sshUser": null, - "sshPort": null, - "sshHost": null, - "ssh": false, - "caCertBookmark": null, - "caCert": null, - "certificateBookmark": null, - "certificate": null, - "keyFileBookmark": null, - "keyFile": null, - "ssl": false, - "default": false, - "star": false, - "totalDb": 16, - "db": 0, - "password": "", - "color": "#4B5563", - "host": "localhost", - "keyPrefix": null, - "type": "standalone", - "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9" - }, - { - "srvRecords": [ - { - "priority": null, - "weight": null, - "port": null, - "name": null - } - ], - "useSRVRecords": false, - "natMaps": [ - { - "privateHost": null, - "privatePort": null, - "publicHost": null, - "publicPort": null - } - ], - "enableNatMaps": false, - "clusterOptions": { - "slotsRefreshInterval": 5000, - "slotsRefreshTimeout": 1000, - "retryDelayOnTryAgain": 100, - "retryDelayOnClusterDown": 100, - "retryDelayOnFailover": 100, - "retryDelayOnMoved": 0, - "maxRedirections": 16, - "dnsLookup": false, - "scaleReads": "master", - "startupNodes": [ - { - "host": null, - "port": null - } - ] - }, - "enableStartupNodes": false, - "sentinelOptions": { - "tls": { - "key": null, - "keyBookmark": null, - "ca": null, - "caBookmark": null, - "cert": null, - "certBookmark": null - }, - "preferredSlaves": [ - { - "host": null, - "port": null, - "priority": null - } - ], - "role": null, - "sentinelPassword": null, - "name": null - }, - "enablePreferredSlaves": false, - "sshKeyPassphrase": null, - "sshKeyFileBookmark": null, - "sshKeyFile": null, - "sshUser": null, - "sshPort": null, - "sshHost": null, - "ssh": false, - "caCertBookmark": null, - "caCert": null, - "certificateBookmark": null, - "certificate": null, - "keyFileBookmark": null, - "keyFile": null, - "ssl": false, - "default": false, - "star": false, - "totalDb": 16, - "db": 1, - "password": "vfsd", - "color": "#4B5563", - "port": 1111, - "keyPrefix": null, - "type": "standalone", - "connectionName": "vd", - "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9", - "cluster": true, - "name": "vd long host" - } -] \ No newline at end of file diff --git a/tests/e2e/test-data/racompass-valid.json b/tests/e2e/test-data/racompass-valid.json deleted file mode 100644 index dc4e32dcf5..0000000000 --- a/tests/e2e/test-data/racompass-valid.json +++ /dev/null @@ -1,171 +0,0 @@ -[ - { - "srvRecords": [ - { - "priority": null, - "weight": null, - "port": null, - "name": null - } - ], - "useSRVRecords": false, - "natMaps": [ - { - "privateHost": null, - "privatePort": null, - "publicHost": null, - "publicPort": null - } - ], - "enableNatMaps": false, - "clusterOptions": { - "slotsRefreshInterval": 5000, - "slotsRefreshTimeout": 1000, - "retryDelayOnTryAgain": 100, - "retryDelayOnClusterDown": 100, - "retryDelayOnFailover": 100, - "retryDelayOnMoved": 0, - "maxRedirections": 16, - "dnsLookup": false, - "scaleReads": "master", - "startupNodes": [ - { - "host": null, - "port": null - } - ] - }, - "enableStartupNodes": false, - "sentinelOptions": { - "tls": { - "key": null, - "keyBookmark": null, - "ca": null, - "caBookmark": null, - "cert": null, - "certBookmark": null - }, - "preferredSlaves": [ - { - "host": null, - "port": null, - "priority": null - } - ], - "role": null, - "sentinelPassword": null, - "name": null - }, - "enablePreferredSlaves": false, - "sshKeyPassphrase": null, - "sshKeyFileBookmark": null, - "sshKeyFile": null, - "sshUser": null, - "sshPort": null, - "sshHost": null, - "ssh": false, - "caCertBookmark": null, - "caCert": null, - "certificateBookmark": null, - "certificate": null, - "keyFileBookmark": null, - "keyFile": null, - "ssl": false, - "default": false, - "star": false, - "totalDb": 16, - "db": 1, - "password": "", - "color": "#4B5563", - "port": 8100, - "host": "racompassDbWithIndex", - "keyPrefix": null, - "type": "standalone", - "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9" - }, - { - "srvRecords": [ - { - "priority": null, - "weight": null, - "port": null, - "name": null - } - ], - "useSRVRecords": false, - "natMaps": [ - { - "privateHost": null, - "privatePort": null, - "publicHost": null, - "publicPort": null - } - ], - "enableNatMaps": false, - "clusterOptions": { - "slotsRefreshInterval": 5000, - "slotsRefreshTimeout": 1000, - "retryDelayOnTryAgain": 100, - "retryDelayOnClusterDown": 100, - "retryDelayOnFailover": 100, - "retryDelayOnMoved": 0, - "maxRedirections": 16, - "dnsLookup": false, - "scaleReads": "master", - "startupNodes": [ - { - "host": null, - "port": null - } - ] - }, - "enableStartupNodes": false, - "sentinelOptions": { - "tls": { - "key": null, - "keyBookmark": null, - "ca": null, - "caBookmark": null, - "cert": null, - "certBookmark": null - }, - "preferredSlaves": [ - { - "host": null, - "port": null, - "priority": null - } - ], - "role": null, - "sentinelPassword": null, - "name": null - }, - "enablePreferredSlaves": false, - "sshKeyPassphrase": null, - "sshKeyFileBookmark": null, - "sshKeyFile": null, - "sshUser": null, - "sshPort": null, - "sshHost": null, - "ssh": false, - "caCertBookmark": null, - "caCert": null, - "certificateBookmark": null, - "certificate": null, - "keyFileBookmark": null, - "keyFile": null, - "ssl": false, - "default": false, - "star": false, - "totalDb": 16, - "db": 0, - "password": "vfsd", - "color": "#4B5563", - "host": "localhost", - "port": 1111, - "keyPrefix": null, - "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9", - "cluster": true, - "name": "racompassCluster" - } -] \ No newline at end of file diff --git a/tests/e2e/test-data/rdm-valid.json b/tests/e2e/test-data/rdm-valid.json deleted file mode 100644 index 845fe507e7..0000000000 --- a/tests/e2e/test-data/rdm-valid.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "password": "new", - "filter_history": { - "*": 1, - "stream1": 1 - }, - "host": "localhost", - "name": "vfd das jashd ashdkjh kjhasfh hfjks dahfjk shdk fhskjad hfkj sdhkj fashk dhfk sahkj fhsak dhfskja hsd kfjh dsakj fhsdjk fhskdjal fhksjd hfkjsd hfkjs hakdjfh sjkdah fjksdh fjksdh jkfh ksdh fsdkjhfksjhfkhsdkf hksjdhf kjsdhf skdhf sdf sdfsda fsdfsd fsd fsdf sd f sdf sd f sd fsd f sd f sd fsd f sad fs df sd f s dsa fsdf vfd das jashd ashdkjh", - "ssh_port": 22, - "timeout_connect": 60000, - "timeout_execute": 60000, - "cluster": false, - "invalid_field": "inv", - "db": 0 - }, - { - "host": "rdmWithUsernameAndPass1", - "port": "1561", - "cluster": true, - "username": "rdmUsername", - "auth": "rdmAuth" - }, - { - "auth": "", - "host": "172.30.100.151", - "name": "oss cluster", - "ssh_port": 22, - "cluster": true, - "timeout_connect": 60000, - "timeout_execute": 60000 - }, - { - "auth": "longname database >500 symbols invalid", - "host": "172.30.100.181", - "port": 1000, - "keys_pattern": "*", - "name": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla rutrum nec libero aliquet ultricies. Donec ut blandit dui, ac hendrerit risus. Vestibulum sodales sed risus ac auctor. Integer quis justo vel leo gravida volutpat quis et risus. Nam vestibulum fermentum eros, in ullamcorper nulla laoreet quis. Vestibulum ut arcu nec turpis elementum malesuada. Maecenas congue felis nec posuere ullamcorper. Phasellus auctor leo sit amet ligula scelerisque, quis elementum ligula auctor. Quisque id leo r", - "namespace_separator": ":", - "ssh_port": 22, - "ssl": true, - "ssl_ca_cert_path": "E:/Redis/redisinsight-envs/cluster-tls-client/certs/ca.crt", - "ssl_local_cert_path": "E:/Redis/redisinsight-envs/cluster-tls-client/certs/client.crt", - "ssl_private_key_path": "E:/Redis/redisinsight-envs/cluster-tls-client/certs/client.key", - "timeout_connect": 60000, - "timeout_execute": 60000 - }, - { - "auth": "pass", - "host": "localhost", - "name": "plain-certs+pass", - "port": 65537, - "ssh_port": 22, - "ssl_local_cert_path": "C:/Users/enaboko/Downloads/certificates", - "timeout_connect": 60000, - "timeout_execute": 60000 - }, - { - "host": "rdmOnlyHostPortDB2", - "port": 6379 - } -] diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts deleted file mode 100644 index db100c0727..0000000000 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { rte } from '../../../helpers/constants'; -import { AddRedisDatabasePage, BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; -import { commonUrl } from '../../../helpers/conf'; -import { acceptLicenseTerms, clickOnEditDatabaseByName } from '../../../helpers/database'; -import { deleteStandaloneDatabasesByNamesApi } from '../../../helpers/api/api-database'; -import { DatabasesActions } from '../../../common-actions/databases-actions'; - -const browserPage = new BrowserPage(); -const myRedisDatabasePage = new MyRedisDatabasePage(); -const databasesActions = new DatabasesActions(); -const addRedisDatabasePage = new AddRedisDatabasePage(); - -const invalidJsonPath = '../../../test-data/racompass-invalid.json'; -const rdmData = { - type: 'rdm', - path: '../../../test-data/rdm-valid.json', - dbNames: ['rdmWithUsernameAndPass1:1561', 'rdmOnlyHostPortDB2:6379'], - userName: 'rdmUsername', - password: 'rdmAuth', - connectionType: 'Cluster' -}; -const dbData = [ - { - type: 'racompass', - path: '../../../test-data/racompass-valid.json', - dbNames: ['racompassCluster', 'racompassDbWithIndex:8100 [1]'] - }, - { - type: 'ardm', - path: '../../../test-data/ardm-valid.ano', - dbNames: ['ardmNoName:12001', 'ardmWithPassAndUsername'] - } -]; -const databases = [ - rdmData.dbNames[0], - rdmData.dbNames[1], - dbData[0].dbNames[0], - dbData[0].dbNames[1].split(' ')[0], - dbData[1].dbNames[0], - dbData[1].dbNames[1] -]; - -fixture `Import databases` - .meta({ type: 'critical_path', rte: rte.standalone }) - .page(commonUrl); -test - .before(async() => { - await acceptLicenseTerms(); - }) - .after(async() => { - // Delete databases - deleteStandaloneDatabasesByNamesApi(databases); - })('Connection import from JSON', async t => { - const tooltipText = 'Import Database Connections'; - const partialImportedMsg = 'Successfully added 2 of 6 database connections'; - - // Verify that user can see the “Import Database Connections” tooltip - await t.hover(myRedisDatabasePage.importDatabasesBtn); - await t.hover(myRedisDatabasePage.importDatabasesBtn); - await t.expect(browserPage.tooltip.innerText).contains(tooltipText, 'The tooltip message not displayed/correct'); - - // Verify that Import dialogue is not closed when clicking any area outside the box - await t.click(myRedisDatabasePage.importDatabasesBtn); - await t.expect(myRedisDatabasePage.importDbDialog.exists).ok('Import Database Connections dialog not opened'); - await t.click(myRedisDatabasePage.myRedisDBButton); - await t.expect(myRedisDatabasePage.importDbDialog.exists).ok('Import Database Connections dialog not displayed'); - - // Verify that user see the message when parse error appears - await t - .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [invalidJsonPath]) - .click(myRedisDatabasePage.submitImportBtn) - .expect(myRedisDatabasePage.failedImportMessage.exists).ok('Failed to add database message not displayed'); - - // Verify that success message is displayed - await t.click(myRedisDatabasePage.closeDialogBtn); - await databasesActions.importDatabase(rdmData.path); - await t.expect(myRedisDatabasePage.successImportMessage.textContent).contains(partialImportedMsg, 'Successfully added databases number not correct'); - - // Verify that list of databases is reloaded when database added - await t.click(myRedisDatabasePage.okDialogBtn); - await databasesActions.verifyDatabasesDisplayed(rdmData.dbNames); - - // Verify that user can import database with all data - await clickOnEditDatabaseByName(rdmData.dbNames[0]); - // Verify username imported - await t.expect(addRedisDatabasePage.usernameInput.value).eql(rdmData.userName); - // Verify password imported - await t.click(addRedisDatabasePage.showPasswordBtn); - await t.expect(addRedisDatabasePage.passwordInput.value).eql(rdmData.password); - // Verify cluster connection type imported - await t.expect(addRedisDatabasePage.connectionType.textContent).eql(rdmData.connectionType); - - // Verify that user can import files from Racompass, ARDM, RDM - for (const db of dbData) { - await databasesActions.importDatabase(db.path, db.type); - await t.click(myRedisDatabasePage.okDialogBtn); - await databasesActions.verifyDatabasesDisplayed(db.dbNames); - } - }); From dbe45dfbe789c66e8a9b49a79c0882e088169be3 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 16 Nov 2022 22:00:33 +0100 Subject: [PATCH 10/57] upd --- .../e2e/tests/critical-path/database/import-databases.e2e.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index db100c0727..ae0319325c 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -71,16 +71,17 @@ test .click(myRedisDatabasePage.submitImportBtn) .expect(myRedisDatabasePage.failedImportMessage.exists).ok('Failed to add database message not displayed'); - // Verify that success message is displayed + // Verify that user can import database with mandatory fields await t.click(myRedisDatabasePage.closeDialogBtn); await databasesActions.importDatabase(rdmData.path); + // Verify that success message is displayed await t.expect(myRedisDatabasePage.successImportMessage.textContent).contains(partialImportedMsg, 'Successfully added databases number not correct'); // Verify that list of databases is reloaded when database added await t.click(myRedisDatabasePage.okDialogBtn); await databasesActions.verifyDatabasesDisplayed(rdmData.dbNames); - // Verify that user can import database with all data + // Verify that user can import database with mandatory+optional data await clickOnEditDatabaseByName(rdmData.dbNames[0]); // Verify username imported await t.expect(addRedisDatabasePage.usernameInput.value).eql(rdmData.userName); From 3fc18725dfbfc442ed72637468d6f2eafd1e7df4 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 17 Nov 2022 10:53:23 +0100 Subject: [PATCH 11/57] fix --- tests/e2e/common-actions/databases-actions.ts | 2 +- tests/e2e/tests/critical-path/database/import-databases.e2e.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/common-actions/databases-actions.ts b/tests/e2e/common-actions/databases-actions.ts index 129b24dbf7..a33e0b22f7 100644 --- a/tests/e2e/common-actions/databases-actions.ts +++ b/tests/e2e/common-actions/databases-actions.ts @@ -9,7 +9,7 @@ export class DatabasesActions { * @param databases The list of databases to verify */ async verifyDatabasesDisplayed(databases: string[]): Promise { - for (const db in databases) { + for (const db of databases) { const databaseName = myRedisDatabasePage.dbNameList.withText(db); await t.expect(databaseName.exists).ok(`"${db}" database doesn't exist`); } diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index ae0319325c..dcbe4ed2bf 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -31,6 +31,7 @@ const dbData = [ dbNames: ['ardmNoName:12001', 'ardmWithPassAndUsername'] } ]; +// List of all created databases to delete const databases = [ rdmData.dbNames[0], rdmData.dbNames[1], From aa9bd15cf2b1373843ed36f6de73f107e7f5be81 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 17 Nov 2022 12:27:14 +0100 Subject: [PATCH 12/57] add test for remove file --- tests/e2e/pageObjects/my-redis-databases-page.ts | 1 + .../critical-path/database/import-databases.e2e.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index 89a78e35da..668c1d5f40 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -34,6 +34,7 @@ export class MyRedisDatabasePage { submitImportBtn = Selector('[data-testid=submit-btn]'); closeDialogBtn = Selector('[aria-label="Closes this modal window"]'); okDialogBtn = Selector('[data-testid=ok-btn]'); + removeImportedFileBtn = Selector('[aria-label="Clear selected files"]'); //CHECKBOXES selectAllCheckbox = Selector('[data-test-subj=checkboxSelectAll]'); //ICONS diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index dcbe4ed2bf..2e7a1e91ce 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -17,7 +17,8 @@ const rdmData = { dbNames: ['rdmWithUsernameAndPass1:1561', 'rdmOnlyHostPortDB2:6379'], userName: 'rdmUsername', password: 'rdmAuth', - connectionType: 'Cluster' + connectionType: 'Cluster', + fileName: 'rdm-valid.json' }; const dbData = [ { @@ -54,6 +55,7 @@ test })('Connection import from JSON', async t => { const tooltipText = 'Import Database Connections'; const partialImportedMsg = 'Successfully added 2 of 6 database connections'; + const defaultText = 'Select or drag and drop a file'; // Verify that user can see the “Import Database Connections” tooltip await t.hover(myRedisDatabasePage.importDatabasesBtn); @@ -72,6 +74,15 @@ test .click(myRedisDatabasePage.submitImportBtn) .expect(myRedisDatabasePage.failedImportMessage.exists).ok('Failed to add database message not displayed'); + // Verify that user can remove file from import input + await t.click(myRedisDatabasePage.closeDialogBtn); + await t.click(myRedisDatabasePage.importDatabasesBtn); + await t.setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [rdmData.path]); + await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(rdmData.fileName, 'Filename not displayed in import input'); + // Click on remove button + await t.click(myRedisDatabasePage.removeImportedFileBtn); + await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(defaultText, 'File not removed from import input'); + // Verify that user can import database with mandatory fields await t.click(myRedisDatabasePage.closeDialogBtn); await databasesActions.importDatabase(rdmData.path); From 000a6327e7e5ee1c38b5aa4db751519511a4cb4d Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Fri, 18 Nov 2022 10:07:01 +0400 Subject: [PATCH 13/57] #RI-3852 - update text --- .../import-databases-dialog/ImportDatabasesDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx index ea91f9f781..8215352ca8 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx @@ -95,7 +95,7 @@ const ImportDatabasesDialog = ({ onClose }: Props) => { /> {isInvalid && ( - File should not exceed {MAX_MB_FILE} mb + File should not exceed {MAX_MB_FILE} MB )} From 4be338b0e43cf83a5e1a1cc77b85e53f587ab029 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Fri, 18 Nov 2022 10:09:21 +0100 Subject: [PATCH 14/57] fixes by comments --- tests/e2e/common-actions/databases-actions.ts | 29 +++++++++++++++---- .../database/import-databases.e2e.ts | 8 ++--- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/tests/e2e/common-actions/databases-actions.ts b/tests/e2e/common-actions/databases-actions.ts index a33e0b22f7..3167ce1b2c 100644 --- a/tests/e2e/common-actions/databases-actions.ts +++ b/tests/e2e/common-actions/databases-actions.ts @@ -17,15 +17,34 @@ export class DatabasesActions { /** * Import database using file - * @param pathToFile The path to file for import - * @param databaseType The database type + * @param fileParameters The arguments of imported file */ - async importDatabase(pathToFile: string, databaseType = ''): Promise { + async importDatabase(fileParameters: ImportDatabaseParameters): Promise { await t .click(myRedisDatabasePage.importDatabasesBtn) - .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [pathToFile]) + .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [fileParameters.path]) .click(myRedisDatabasePage.submitImportBtn) - .expect(myRedisDatabasePage.successImportMessage.exists).ok(`Successfully added ${databaseType} databases message not displayed`); + .expect(myRedisDatabasePage.successImportMessage.exists).ok(`Successfully added ${fileParameters.type} databases message not displayed`); } } + +/** + * Import database parameters + * @param path The path to file + * @param type The type of application + * @param dbNames The names of databases + * @param userName The username of db + * @param password The password of db + * @param connectionType The connection type of db + * @param fileName The file name + */ +export type ImportDatabaseParameters = { + path: string, + type?: string, + dbNames?: string[], + userName?: string, + password?: string, + connectionType?: string, + fileName?: string +}; diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index 2e7a1e91ce..ce738b9dac 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -58,7 +58,7 @@ test const defaultText = 'Select or drag and drop a file'; // Verify that user can see the “Import Database Connections” tooltip - await t.hover(myRedisDatabasePage.importDatabasesBtn); + await t.expect(myRedisDatabasePage.importDatabasesBtn.visible).ok('The import databases button not displayed'); await t.hover(myRedisDatabasePage.importDatabasesBtn); await t.expect(browserPage.tooltip.innerText).contains(tooltipText, 'The tooltip message not displayed/correct'); @@ -85,9 +85,9 @@ test // Verify that user can import database with mandatory fields await t.click(myRedisDatabasePage.closeDialogBtn); - await databasesActions.importDatabase(rdmData.path); + await databasesActions.importDatabase(rdmData); // Verify that success message is displayed - await t.expect(myRedisDatabasePage.successImportMessage.textContent).contains(partialImportedMsg, 'Successfully added databases number not correct'); + await t.expect(myRedisDatabasePage.successImportMessage.textContent).contains(partialImportedMsg, 'Databases not imported successfully'); // Verify that list of databases is reloaded when database added await t.click(myRedisDatabasePage.okDialogBtn); @@ -105,7 +105,7 @@ test // Verify that user can import files from Racompass, ARDM, RDM for (const db of dbData) { - await databasesActions.importDatabase(db.path, db.type); + await databasesActions.importDatabase(db); await t.click(myRedisDatabasePage.okDialogBtn); await databasesActions.verifyDatabasesDisplayed(db.dbNames); } From f945ff1d06b35641cbb3c7a20db5bc464ce96266 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 5 Dec 2022 15:45:47 +0200 Subject: [PATCH 15/57] #RI-3902 import result with statuses --- .../api/src/__mocks__/database-import.ts | 37 ++++- .../api/src/common/exceptions/index.ts | 1 + .../common/exceptions/validation.exception.ts | 3 + .../api/src/constants/telemetry-events.ts | 1 + .../database-import.analytics.ts | 20 ++- .../database-import.controller.ts | 4 +- .../database-import.service.spec.ts | 6 +- .../database-import.service.ts | 148 ++++++++++++------ .../dto/database-import.response.ts | 83 +++++++++- .../POST-databases-import.test.ts | 30 +++- 10 files changed, 254 insertions(+), 79 deletions(-) create mode 100644 redisinsight/api/src/common/exceptions/index.ts create mode 100644 redisinsight/api/src/common/exceptions/validation.exception.ts diff --git a/redisinsight/api/src/__mocks__/database-import.ts b/redisinsight/api/src/__mocks__/database-import.ts index 2ce3ed50a5..210c90e1e4 100644 --- a/redisinsight/api/src/__mocks__/database-import.ts +++ b/redisinsight/api/src/__mocks__/database-import.ts @@ -1,7 +1,7 @@ -import { DatabaseImportResponse } from 'src/modules/database-import/dto/database-import.response'; +import { DatabaseImportResponse, DatabaseImportStatus } from 'src/modules/database-import/dto/database-import.response'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mockDatabase } from 'src/__mocks__/databases'; -import { ValidationError } from 'class-validator'; +import { ValidationException } from 'src/common/exceptions'; export const mockDatabasesToImportArray = new Array(10).fill(mockDatabase); @@ -12,10 +12,33 @@ export const mockDatabaseImportFile = { buffer: Buffer.from(JSON.stringify(mockDatabasesToImportArray)), }; +export const mockDatabaseImportResultSuccess = { + index: 0, + status: DatabaseImportStatus.Success, + host: mockDatabase.host, + port: mockDatabase.port, +}; + +export const mockDatabaseImportResultFail = { + index: 0, + status: DatabaseImportStatus.Fail, + host: mockDatabase.host, + port: mockDatabase.port, + error: new BadRequestException(), +}; + export const mockDatabaseImportResponse = Object.assign(new DatabaseImportResponse(), { total: 10, - success: 7, - errors: [new ValidationError(), new BadRequestException(), new ForbiddenException()], + success: (new Array(7).fill(mockDatabaseImportResultSuccess)).map((v, index) => ({ + ...v, + index: index + 3, + })), + partial: [], + fail: [new ValidationException([]), new BadRequestException(), new ForbiddenException()].map((error, index) => ({ + ...mockDatabaseImportResultFail, + index, + error, + })), }); export const mockDatabaseImportParseFailedAnalyticsPayload = { @@ -23,12 +46,12 @@ export const mockDatabaseImportParseFailedAnalyticsPayload = { }; export const mockDatabaseImportFailedAnalyticsPayload = { - failed: mockDatabaseImportResponse.errors.length, - errors: ['ValidationError', 'BadRequestException', 'ForbiddenException'], + failed: mockDatabaseImportResponse.fail.length, + errors: ['ValidationException', 'BadRequestException', 'ForbiddenException'], }; export const mockDatabaseImportSucceededAnalyticsPayload = { - succeed: mockDatabaseImportResponse.success, + succeed: mockDatabaseImportResponse.success.length, }; export const mockDatabaseImportAnalytics = jest.fn(() => ({ diff --git a/redisinsight/api/src/common/exceptions/index.ts b/redisinsight/api/src/common/exceptions/index.ts new file mode 100644 index 0000000000..b640b9cfae --- /dev/null +++ b/redisinsight/api/src/common/exceptions/index.ts @@ -0,0 +1 @@ +export * from './validation.exception'; diff --git a/redisinsight/api/src/common/exceptions/validation.exception.ts b/redisinsight/api/src/common/exceptions/validation.exception.ts new file mode 100644 index 0000000000..9ce546d66c --- /dev/null +++ b/redisinsight/api/src/common/exceptions/validation.exception.ts @@ -0,0 +1,3 @@ +import { BadRequestException } from '@nestjs/common'; + +export class ValidationException extends BadRequestException {} diff --git a/redisinsight/api/src/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index f583d17048..15b36f1531 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -18,6 +18,7 @@ export enum TelemetryEvents { DatabaseImportParseFailed = 'CONFIG_DATABASES_REDIS_IMPORT_PARSE_FAILED', DatabaseImportFailed = 'CONFIG_DATABASES_REDIS_IMPORT_FAILED', DatabaseImportSucceeded = 'CONFIG_DATABASES_REDIS_IMPORT_SUCCEEDED', + DatabaseImportPartiallySucceeded = 'CONFIG_DATABASES_REDIS_IMPORT_PARTIALLY_SUCCEEDED', // Events for autodiscovery flows REClusterDiscoverySucceed = 'CONFIG_DATABASES_RE_CLUSTER_AUTODISCOVERY_SUCCEEDED', diff --git a/redisinsight/api/src/modules/database-import/database-import.analytics.ts b/redisinsight/api/src/modules/database-import/database-import.analytics.ts index e363db5a0e..6d4894d448 100644 --- a/redisinsight/api/src/modules/database-import/database-import.analytics.ts +++ b/redisinsight/api/src/modules/database-import/database-import.analytics.ts @@ -11,21 +11,31 @@ export class DatabaseImportAnalytics extends TelemetryBaseService { } sendImportResults(importResult: DatabaseImportResponse): void { - if (importResult.success) { + if (importResult.success?.length) { this.sendEvent( TelemetryEvents.DatabaseImportSucceeded, { - succeed: importResult.success, + succeed: importResult.success.length, }, ); } - if (importResult.errors?.length) { + if (importResult.fail?.length) { this.sendEvent( TelemetryEvents.DatabaseImportFailed, { - failed: importResult.errors.length, - errors: importResult.errors.map((e) => (e?.constructor?.name || 'UncaughtError')), + failed: importResult.fail.length, + errors: importResult.fail.map((res) => (res?.error?.constructor?.name || 'UncaughtError')), + }, + ); + } + + if (importResult.partial?.length) { + this.sendEvent( + TelemetryEvents.DatabaseImportPartiallySucceeded, + { + partially: importResult.partial.length, + errors: importResult.partial.map((res) => (res?.error?.constructor?.name || 'UncaughtError')), }, ); } 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 3bfabfb86e..eb0d02780e 100644 --- a/redisinsight/api/src/modules/database-import/database-import.controller.ts +++ b/redisinsight/api/src/modules/database-import/database-import.controller.ts @@ -1,6 +1,7 @@ import { + ClassSerializerInterceptor, Controller, HttpCode, Post, UploadedFile, - UseInterceptors, UsePipes, ValidationPipe, + UseInterceptors, UsePipes, ValidationPipe } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiResponse, ApiTags, @@ -10,6 +11,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { DatabaseImportResponse } from 'src/modules/database-import/dto/database-import.response'; @UsePipes(new ValidationPipe({ transform: true })) +@UseInterceptors(ClassSerializerInterceptor) @ApiTags('Database') @Controller('/databases') export class DatabaseImportController { diff --git a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts index 49de47ea42..1c38ba3435 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts @@ -138,7 +138,7 @@ describe('DatabaseImportService', () => { it('should create standalone database', async () => { await service['createDatabase']({ ...mockDatabase, - }); + }, 0); expect(databaseRepository.create).toHaveBeenCalledWith({ ...pick(mockDatabase, ['host', 'port', 'name', 'connectionType']), @@ -148,7 +148,7 @@ describe('DatabaseImportService', () => { await service['createDatabase']({ ...mockDatabase, name: undefined, - }); + }, 0); expect(databaseRepository.create).toHaveBeenCalledWith({ ...pick(mockDatabase, ['host', 'port', 'name', 'connectionType']), @@ -159,7 +159,7 @@ describe('DatabaseImportService', () => { await service['createDatabase']({ ...mockDatabase, cluster: true, - }); + }, 0); expect(databaseRepository.create).toHaveBeenCalledWith({ ...pick(mockDatabase, ['host', 'port', 'name']), diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index 8fdc6c9fdd..b551e79b28 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -1,18 +1,24 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { isArray, get, set } from 'lodash'; +import { HttpException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { get, isArray, set } from 'lodash'; import { Database } from 'src/modules/database/models/database'; import { plainToClass } from 'class-transformer'; import { ConnectionType } from 'src/modules/database/entities/database.entity'; import { DatabaseRepository } from 'src/modules/database/repositories/database.repository'; -import { DatabaseImportResponse } from 'src/modules/database-import/dto/database-import.response'; -import { Validator } from 'class-validator'; +import { + DatabaseImportResponse, + DatabaseImportResult, + DatabaseImportStatus, +} from 'src/modules/database-import/dto/database-import.response'; +import { ValidationError, Validator } from 'class-validator'; import { ImportDatabaseDto } from 'src/modules/database-import/dto/import.database.dto'; import { classToClass } from 'src/utils'; import { DatabaseImportAnalytics } from 'src/modules/database-import/database-import.analytics'; import { + NoDatabaseImportFileProvidedException, SizeLimitExceededDatabaseImportFileException, - NoDatabaseImportFileProvidedException, UnableToParseDatabaseImportFileException, + UnableToParseDatabaseImportFileException, } from 'src/modules/database-import/exceptions'; +import { ValidationException } from 'src/common/exceptions'; @Injectable() export class DatabaseImportService { @@ -61,28 +67,33 @@ export class DatabaseImportService { let response = { total: items.length, - success: 0, - errors: [], + success: [], + partial: [], + fail: [], }; // it is very important to insert databases on-by-one to avoid db constraint errors - await items.reduce((prev, item) => prev.finally(() => this.createDatabase(item) - .then(() => { - response.success += 1; - }) - .catch((e) => { - let error = e; - if (isArray(e)) { - [error] = e; + await items.reduce((prev, item, index) => prev.finally(() => this.createDatabase(item, index) + .then((result) => { + switch (result.status) { + case DatabaseImportStatus.Fail: + response.fail.push(result); + break; + case DatabaseImportStatus.Partial: + response.partial.push(result); + break; + case DatabaseImportStatus.Success: + response.success.push(result); + break; + default: + // do not include into repost, since some unexpected behaviour } - this.logger.warn(`Unable to import database: ${error?.constructor?.name || 'UncaughtError'}`, error); - response.errors.push(error); })), Promise.resolve()); - this.analytics.sendImportResults(response); - response = plainToClass(DatabaseImportResponse, response); + this.analytics.sendImportResults(response); + return response; } catch (e) { this.logger.warn(`Unable to import databases: ${e?.constructor?.name || 'UncaughtError'}`, e); @@ -97,51 +108,84 @@ export class DatabaseImportService { * Map data to known model, validate it and create database if possible * Note: will not create connection, simply create database * @param item + * @param index * @private */ - private async createDatabase(item: any): Promise { - const data: any = {}; + private async createDatabase(item: any, index: number): Promise { + try { + const data: any = {}; - this.fieldsMapSchema.forEach(([field, paths]) => { - let value; + this.fieldsMapSchema.forEach(([field, paths]) => { + let value; - paths.every((path) => { - value = get(item, path); - return value === undefined; + paths.every((path) => { + value = get(item, path); + return value === undefined; + }); + + set(data, field, value); }); - set(data, field, value); - }); + // set database name if needed + if (!data.name) { + data.name = `${data.host}:${data.port}`; + } - // set database name if needed - if (!data.name) { - data.name = `${data.host}:${data.port}`; - } + // determine database type + if (data.isCluster) { + data.connectionType = ConnectionType.CLUSTER; + } else { + data.connectionType = ConnectionType.STANDALONE; + } - // determine database type - if (data.isCluster) { - data.connectionType = ConnectionType.CLUSTER; - } else { - data.connectionType = ConnectionType.STANDALONE; - } + const dto = plainToClass( + ImportDatabaseDto, + // additionally replace empty strings ("") with null + Object.keys(data) + .reduce((acc, key) => { + acc[key] = data[key] === '' ? null : data[key]; + return acc; + }, {}), + ); + + await this.validator.validateOrReject(dto, { + whitelist: true, + }); + + const database = classToClass(Database, dto); - const dto = plainToClass( - ImportDatabaseDto, - // additionally replace empty strings ("") with null - Object.keys(data) - .reduce((acc, key) => { - acc[key] = data[key] === '' ? null : data[key]; - return acc; - }, {}), - ); + await this.databaseRepository.create(database); - await this.validator.validateOrReject(dto, { - whitelist: true, - }); + return { + index, + status: DatabaseImportStatus.Success, + host: database.host, + port: database.port, + }; + } catch (e) { + let error = e; + if (isArray(e)) { + [error] = e; + } - const database = classToClass(Database, dto); + if (error instanceof ValidationError) { + error = new ValidationException(Object.values(error?.constraints || {}) || 'Bad request'); + } - return this.databaseRepository.create(database); + if (!(error instanceof HttpException)) { + error = new InternalServerErrorException(error?.message); + } + + this.logger.warn(`Unable to import database: ${error?.constructor?.name || 'UncaughtError'}`, error); + + return { + index, + status: DatabaseImportStatus.Fail, + host: item?.host, + port: item?.port, + error, + }; + } } /** diff --git a/redisinsight/api/src/modules/database-import/dto/database-import.response.ts b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts index dfe8f374da..a830dadced 100644 --- a/redisinsight/api/src/modules/database-import/dto/database-import.response.ts +++ b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts @@ -1,5 +1,60 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Exclude, Expose } from 'class-transformer'; +import { isArray } from 'lodash'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Transform, Type } from 'class-transformer'; + +export enum DatabaseImportStatus { + Success = 'success', + Partial = 'partial', + Fail = 'fail', +} + +export class DatabaseImportResult { + @ApiProperty({ + description: 'Entry index from original json', + type: Number, + }) + @Expose() + index: number; + + @ApiProperty({ + description: 'Import status', + enum: DatabaseImportStatus, + }) + @Expose() + status: DatabaseImportStatus; + + @ApiPropertyOptional({ + description: 'Database host', + type: String, + }) + @Expose() + host?: string; + + @ApiPropertyOptional({ + description: 'Database port', + type: Number, + }) + @Expose() + port?: number; + + @ApiPropertyOptional({ + description: 'Error message if any', + type: String, + }) + @Expose() + @Transform((e) => { + if (!e) { + return undefined; + } + + if (e?.response?.message) { + return isArray(e.response.message) ? e.response.message[e.response.message.length - 1] : e.response.message; + } + + return e?.message || 'Unhandled Error'; + }, { toPlainOnly: true }) + error?: Error; +} export class DatabaseImportResponse { @ApiProperty({ @@ -10,12 +65,26 @@ export class DatabaseImportResponse { total: number; @ApiProperty({ - description: 'Number of imported database', - type: Number, + description: 'List of successfully imported database', + type: DatabaseImportResult, + }) + @Expose() + @Type(() => DatabaseImportResult) + success: DatabaseImportResult[]; + + @ApiProperty({ + description: 'List of partially imported database', + type: DatabaseImportResult, }) @Expose() - success: number; + @Type(() => DatabaseImportResult) + partial: DatabaseImportResult[]; - @Exclude() - errors: Error[]; + @ApiProperty({ + description: 'List of databases failed to import', + type: DatabaseImportResult, + }) + @Expose() + @Type(() => DatabaseImportResult) + fail: DatabaseImportResult[]; } diff --git a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts index d46dd73631..cd4d8063c5 100644 --- a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts +++ b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts @@ -66,7 +66,8 @@ describe('POST /databases/import', () => { attach: ['file', Buffer.from(JSON.stringify([database])), 'file.json'], responseBody: { total: 1, - success: 0, + success: [], + partial: [], } } }) @@ -148,7 +149,14 @@ describe('POST /databases/import', () => { ])), 'file.json'], responseBody: { total: 1, - success: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat1.host, + port: parseInt(importDatabaseFormat1.port, 10), + }], + partial: [], + fail: [], }, }); @@ -178,7 +186,14 @@ describe('POST /databases/import', () => { ])), 'file.json'], responseBody: { total: 1, - success: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat1.host, + port: parseInt(importDatabaseFormat1.port, 10), + }], + partial: [], + fail: [], }, }); @@ -252,7 +267,14 @@ describe('POST /databases/import', () => { ])).toString('base64')), 'file.ano'], responseBody: { total: 1, - success: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat1.host, + port: parseInt(importDatabaseFormat1.port, 10), + }], + partial: [], + fail: [], }, }); From 84cea2b730143395990bc698357ad9fb22946518 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 5 Dec 2022 16:29:08 +0200 Subject: [PATCH 16/57] #RI-3902 add transformers for host, port fields + add tests (resolve pr comments) --- .../database-import/dto/database-import.response.ts | 4 +++- .../database-import/POST-databases-import.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/database-import/dto/database-import.response.ts b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts index a830dadced..535c8d7869 100644 --- a/redisinsight/api/src/modules/database-import/dto/database-import.response.ts +++ b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts @@ -1,4 +1,4 @@ -import { isArray } from 'lodash'; +import { isArray, isString, isNumber } from 'lodash'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Expose, Transform, Type } from 'class-transformer'; @@ -28,6 +28,7 @@ export class DatabaseImportResult { type: String, }) @Expose() + @Transform((v) => (isString(v) ? v : undefined), { toPlainOnly: true }) host?: string; @ApiPropertyOptional({ @@ -35,6 +36,7 @@ export class DatabaseImportResult { type: Number, }) @Expose() + @Transform((v) => (isNumber(v) ? v : undefined), { toPlainOnly: true }) port?: number; @ApiPropertyOptional({ diff --git a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts index cd4d8063c5..f848c06bc0 100644 --- a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts +++ b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts @@ -68,6 +68,18 @@ describe('POST /databases/import', () => { total: 1, success: [], partial: [], + }, + checkFn: ({ body }) => { + expect(body.fail.length).to.eq(1); + expect(body.fail[0].status).to.eq('fail'); + expect(body.fail[0].index).to.eq(0); + expect(body.fail[0].error).to.be.a('string'); + if (body.fail[0].host) { + expect(body.fail[0].host).to.be.a('string'); + } + if (body.fail[0].port) { + expect(body.fail[0].port).to.be.a('number'); + } } } }) From 937e3432b3d875bcbbe2dde05efc8ea93e93329d Mon Sep 17 00:00:00 2001 From: Zalenski Egor <63463140+zalenskiSofteq@users.noreply.github.com> Date: Tue, 6 Dec 2022 18:40:36 +0100 Subject: [PATCH 17/57] #RI-3219 - [BE] Indicate new connections --- .../migration/1670252337342-database-new.ts | 20 +++++++++++++++++++ redisinsight/api/migration/index.ts | 2 ++ redisinsight/api/src/__mocks__/databases.ts | 6 ++++++ .../database-import.service.spec.ts | 5 ++++- .../database-import.service.ts | 3 +++ .../dto/import.database.dto.ts | 2 +- .../database-connection.service.spec.ts | 2 +- .../database/database-connection.service.ts | 2 ++ .../database/entities/database.entity.ts | 4 ++++ .../src/modules/database/models/database.ts | 10 ++++++++++ .../database/providers/database.factory.ts | 2 ++ .../repositories/local.database.repository.ts | 2 +- .../test/api/database/POST-databases.test.ts | 4 +++- 13 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 redisinsight/api/migration/1670252337342-database-new.ts diff --git a/redisinsight/api/migration/1670252337342-database-new.ts b/redisinsight/api/migration/1670252337342-database-new.ts new file mode 100644 index 0000000000..be73bccf35 --- /dev/null +++ b/redisinsight/api/migration/1670252337342-database-new.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class databaseNew1670252337342 implements MigrationInterface { + name = 'databaseNew1670252337342' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean, "verifyServerCert" boolean, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar DEFAULT ('[]'), "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, "encryption" varchar, "tlsServername" varchar, "new" boolean, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean, "verifyServerCert" boolean, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar DEFAULT ('[]'), "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, "encryption" varchar, "tlsServername" varchar, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 954f1fd4e4..a2a4d1b284 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -21,6 +21,7 @@ import { databaseAnalysis1664785208236 } from './1664785208236-database-analysis import { databaseAnalysisExpirationGroups1664886479051 } from './1664886479051-database-analysis-expiration-groups'; import { workbenchExecutionTime1667368983699 } from './1667368983699-workbench-execution-time'; import { database1667477693934 } from './1667477693934-database'; +import { databaseNew1670252337342 } from './1670252337342-database-new'; export default [ initialMigration1614164490968, @@ -46,4 +47,5 @@ export default [ databaseAnalysisExpirationGroups1664886479051, workbenchExecutionTime1667368983699, database1667477693934, + databaseNew1670252337342, ]; diff --git a/redisinsight/api/src/__mocks__/databases.ts b/redisinsight/api/src/__mocks__/databases.ts index 580c710427..52227c6836 100644 --- a/redisinsight/api/src/__mocks__/databases.ts +++ b/redisinsight/api/src/__mocks__/databases.ts @@ -27,6 +27,7 @@ export const mockDatabase = Object.assign(new Database(), { host: '127.0.100.1', port: 6379, connectionType: ConnectionType.STANDALONE, + new: false, }); export const mockDatabaseEntity = Object.assign(new DatabaseEntity(), { @@ -115,6 +116,11 @@ export const mockClusterDatabaseWithTlsAuthEntity = Object.assign(new DatabaseEn nodes: JSON.stringify(mockClusterNodes), }); +export const mockNewDatabase = Object.assign(new Database(), { + ...mockDatabase, + new: true, +}); + export const mockClientMetadata: ClientMetadata = { databaseId: mockDatabase.id, namespace: AppTool.Common, diff --git a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts index 49de47ea42..7ff52aeae5 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts @@ -118,7 +118,7 @@ describe('DatabaseImportService', () => { } }); - it('should faile due to incorrect base64 + truncate filename', async () => { + it('should fail due to incorrect base64 + truncate filename', async () => { try { await service.import({ ...mockDatabaseImportFile, @@ -142,6 +142,7 @@ describe('DatabaseImportService', () => { expect(databaseRepository.create).toHaveBeenCalledWith({ ...pick(mockDatabase, ['host', 'port', 'name', 'connectionType']), + new: true, }); }); it('should create standalone with created name', async () => { @@ -153,6 +154,7 @@ describe('DatabaseImportService', () => { expect(databaseRepository.create).toHaveBeenCalledWith({ ...pick(mockDatabase, ['host', 'port', 'name', 'connectionType']), name: `${mockDatabase.host}:${mockDatabase.port}`, + new: true, }); }); it('should create cluster database', async () => { @@ -164,6 +166,7 @@ describe('DatabaseImportService', () => { expect(databaseRepository.create).toHaveBeenCalledWith({ ...pick(mockDatabase, ['host', 'port', 'name']), connectionType: ConnectionType.CLUSTER, + new: true, }); }); }); diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index 8fdc6c9fdd..ee0bb670f6 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -102,6 +102,9 @@ export class DatabaseImportService { private async createDatabase(item: any): Promise { const data: any = {}; + // set this is a new connection + data.new = true + this.fieldsMapSchema.forEach(([field, paths]) => { let value; diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index de696bcafc..14e7ad5dd3 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -7,7 +7,7 @@ import { export class ImportDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', - 'connectionType', + 'connectionType', 'new', ] as const) { @Expose() @IsNotEmpty() diff --git a/redisinsight/api/src/modules/database/database-connection.service.spec.ts b/redisinsight/api/src/modules/database/database-connection.service.spec.ts index 029fd625ff..ba8787498c 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.spec.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.spec.ts @@ -82,7 +82,7 @@ describe('DatabaseConnectionService', () => { }); describe('createClient', () => { - it('should create client for standalone datbaase', async () => { + it('should create client for standalone database', async () => { expect(await service.createClient(mockClientMetadata)).toEqual(mockIORedisClient); }); it('should throw Unauthorized error in case of NOAUTH', async () => { diff --git a/redisinsight/api/src/modules/database/database-connection.service.ts b/redisinsight/api/src/modules/database/database-connection.service.ts index 932458daaa..b95d276574 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.ts @@ -37,9 +37,11 @@ export class DatabaseConnectionService { }); // refresh modules list and last connected time + // mark database as not a new // will be refreshed after user navigate to particular database from the databases list // Note: move to a different place in case if we need to update such info more often const toUpdate: Partial = { + new: false, lastConnection: new Date(), modules: await this.databaseInfoProvider.determineDatabaseModules(client), }; diff --git a/redisinsight/api/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index 179df10fe0..25be2cc1ad 100644 --- a/redisinsight/api/src/modules/database/entities/database.entity.ts +++ b/redisinsight/api/src/modules/database/entities/database.entity.ts @@ -157,4 +157,8 @@ export class DatabaseEntity { @Column({ nullable: true }) encryption: string; + + @Expose() + @Column({ nullable: true }) + new: boolean; } diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index 8531689622..0834ca80ff 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -203,4 +203,14 @@ export class Database { @Type(() => ClientCertificate) @ValidateNested() clientCert?: ClientCertificate; + + @ApiPropertyOptional({ + description: 'A new created connection', + type: Boolean, + default: false, + }) + @Expose() + @IsOptional() + @IsBoolean({ always: true }) + new?: boolean; } diff --git a/redisinsight/api/src/modules/database/providers/database.factory.ts b/redisinsight/api/src/modules/database/providers/database.factory.ts index a8623558cf..654abae9a3 100644 --- a/redisinsight/api/src/modules/database/providers/database.factory.ts +++ b/redisinsight/api/src/modules/database/providers/database.factory.ts @@ -46,6 +46,8 @@ export class DatabaseFactory { model.modules = await this.databaseInfoProvider.determineDatabaseModules(client); model.lastConnection = new Date(); + model.new = true + await client.disconnect(); return model; diff --git a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts index 120084906a..b0f82d6e59 100644 --- a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts +++ b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts @@ -67,7 +67,7 @@ export class LocalDatabaseRepository extends DatabaseRepository { const entities = await this.repository .createQueryBuilder('d') .select([ - 'd.id', 'd.name', 'd.host', 'd.port', 'd.db', + 'd.id', 'd.name', 'd.host', 'd.port', 'd.db', 'd.new', 'd.connectionType', 'd.modules', 'd.lastConnection', ]) .getMany(); diff --git a/redisinsight/api/test/api/database/POST-databases.test.ts b/redisinsight/api/test/api/database/POST-databases.test.ts index 7d8fee3d41..a52114ff47 100644 --- a/redisinsight/api/test/api/database/POST-databases.test.ts +++ b/redisinsight/api/test/api/database/POST-databases.test.ts @@ -238,7 +238,9 @@ describe('POST /databases', () => { }, }); - expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + const db = await localDb.getInstanceByName(dbName) + expect(db).to.be.an('object'); + expect(db.new).to.eql(true); }); // todo: cover connection error for incorrect username/password }); From 1cce58a737179d603152a040793a9ac785787be3 Mon Sep 17 00:00:00 2001 From: Zalenski Egor <63463140+zalenskiSofteq@users.noreply.github.com> Date: Tue, 6 Dec 2022 18:45:41 +0100 Subject: [PATCH 18/57] #RI-3219 - Indicate new connections --- .../DatabasesListWrapper.spec.tsx | 33 +++++++++++++++++++ .../DatabasesListWrapper.tsx | 29 +++++++++++----- .../DatabasesListComponent/styles.module.scss | 31 +++++++++++++++++ 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx index 4cdcd2dc82..273665373e 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx @@ -2,9 +2,11 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' import { EuiInMemoryTable } from '@elastic/eui' +import { useSelector } from 'react-redux' import { first } from 'lodash' import { ConnectionType } from 'uiSrc/slices/interfaces' +import store, { RootState } from 'uiSrc/slices/store' import DatabasesListWrapper, { Props } from './DatabasesListWrapper' import DatabasesList, { Props as DatabasesListProps } from './DatabasesList/DatabasesList' @@ -16,6 +18,11 @@ jest.mock('./DatabasesList/DatabasesList', () => ({ default: jest.fn(), })) +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn() +})) + const mockInstances = [ { id: 'e37cc441-a4f2-402c-8bdb-fc2413cbbaff', @@ -26,6 +33,7 @@ const mockInstances = [ password: null, connectionType: ConnectionType.Standalone, nameFromProvider: null, + new: true, lastConnection: new Date('2021-04-22T09:03:56.917Z'), }, { @@ -62,6 +70,21 @@ const mockDatabasesList = (props: DatabasesListProps) => (
) +beforeEach(() => { + const state: RootState = store.getState(); + + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => callback({ + ...state, + analytics: { + ...state.analytics + }, + connections: { + ...state.connections, + instances: mockInstances, + } + })) +}) + describe('DatabasesListWrapper', () => { beforeAll(() => { DatabasesList.mockImplementation(mockDatabasesList) @@ -77,4 +100,14 @@ describe('DatabasesListWrapper', () => { fireEvent.click(screen.getByTestId('onDelete-btn')) expect(component).toBeTruthy() }) + + it('should show indicator for a new connection', () => { + const { queryByTestId } = render() + + const dbIdWithNewIndicator = mockInstances.find(({ new: newState }) => newState)?.id ?? '' + const dbIdWithoutNewIndicator = mockInstances.find(({ new: newState }) => !newState)?.id ?? '' + + expect(queryByTestId(`database-status-${dbIdWithNewIndicator}`)).toBeInTheDocument() + expect(queryByTestId(`database-status-${dbIdWithoutNewIndicator}`)).not.toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx index 4ba8195f60..c054534a68 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx @@ -178,12 +178,21 @@ const DatabasesListWrapper = ({ sortable: ({ name }) => name?.toLowerCase(), width: '30%', render: function InstanceCell(name: string = '', instance: Instance) { - const { id, db } = instance + const { id, db, new: newStatus = false } = instance const cellContent = replaceSpaces(name.substring(0, 200)) return (
+ {newStatus && ( + +
+ + )} +
+ +
) } diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss index 3422b0bbf0..986a5659a8 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/styles.module.scss @@ -139,3 +139,34 @@ $breakpoint-l: 1400px; text-decoration: none !important; } } + +.columnNew { + padding: 0 !important; + + .euiFlexItem { + margin: 0 !important; + } +} + +.newStatus { + background-color: var(--euiColorPrimary) !important; + cursor: pointer; + width: 11px !important; + min-width: 11px !important; + height: 11px !important; + border-radius: 6px; +} + +.newStatusAnchor { + margin-top: 20px; + margin-left: -19px; + position: absolute; +} + +.container { + // Database alias column + tr > th:nth-child(2), + tr > td:nth-child(2) { + padding-left: 5px !important; + } +} From 62726454358a343041616a7089ab6f237a1668e2 Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Tue, 6 Dec 2022 19:52:31 +0100 Subject: [PATCH 19/57] #RI-3219 - Integration tests --- .../api/test/api/database-import/POST-databases-import.test.ts | 2 ++ redisinsight/api/test/api/database/GET-databases.test.ts | 1 + redisinsight/api/test/api/database/POST-databases.test.ts | 1 + redisinsight/api/test/api/database/PUT-databases-id.test.ts | 1 + 4 files changed, 5 insertions(+) diff --git a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts index d46dd73631..0afb183d23 100644 --- a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts +++ b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts @@ -158,6 +158,7 @@ describe('POST /databases/import', () => { endpoint: () => request(server).get(`/${constants.API.DATABASES}/${database.id}/connect`), statusCode: 200, }); + expect(database.new).to.eq(true); }); describe('Oss', () => { requirements('!rte.re'); @@ -260,6 +261,7 @@ describe('POST /databases/import', () => { // check connection const database = await localDb.getInstanceByName(name); + expect(database.new).to.eq(true); expect(database.nodes).to.eq('[]'); expect(database.connectionType).to.eq('CLUSTER'); diff --git a/redisinsight/api/test/api/database/GET-databases.test.ts b/redisinsight/api/test/api/database/GET-databases.test.ts index 23ba91e164..41cfba07ee 100644 --- a/redisinsight/api/test/api/database/GET-databases.test.ts +++ b/redisinsight/api/test/api/database/GET-databases.test.ts @@ -11,6 +11,7 @@ const responseSchema = Joi.array().items(Joi.object().keys({ port: Joi.number().integer().required(), db: Joi.number().integer().allow(null).required(), name: Joi.string().required(), + new: Joi.boolean().allow(null).required(), connectionType: Joi.string().valid('STANDALONE', 'SENTINEL', 'CLUSTER').required(), lastConnection: Joi.string().isoDate().allow(null).required(), modules: Joi.array().items(Joi.object().keys({ diff --git a/redisinsight/api/test/api/database/POST-databases.test.ts b/redisinsight/api/test/api/database/POST-databases.test.ts index a52114ff47..ff2f6be0fe 100644 --- a/redisinsight/api/test/api/database/POST-databases.test.ts +++ b/redisinsight/api/test/api/database/POST-databases.test.ts @@ -22,6 +22,7 @@ const dataSchema = Joi.object({ db: Joi.number().integer().allow(null), username: Joi.string().allow(null), password: Joi.string().allow(null), + new: Joi.boolean().allow(null).required(), tls: Joi.boolean().allow(null), tlsServername: Joi.string().allow(null), verifyServerCert: Joi.boolean().allow(null), diff --git a/redisinsight/api/test/api/database/PUT-databases-id.test.ts b/redisinsight/api/test/api/database/PUT-databases-id.test.ts index 8e14b05eb0..c0a98cf03e 100644 --- a/redisinsight/api/test/api/database/PUT-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PUT-databases-id.test.ts @@ -23,6 +23,7 @@ const dataSchema = Joi.object({ db: Joi.number().integer().allow(null), username: Joi.string().allow(null), password: Joi.string().allow(null), + new: Joi.boolean().allow(null).required(), tls: Joi.boolean().allow(null), tlsServername: Joi.string().allow(null), verifyServerCert: Joi.boolean().allow(null), From 5cb450ccd9f24ff0e41b24b947c08590d584058a Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Wed, 7 Dec 2022 00:05:07 +0100 Subject: [PATCH 20/57] #RI-3219 - Integration tests --- redisinsight/api/test/api/database/POST-databases.test.ts | 7 ++++++- .../api/test/api/database/PUT-databases-id.test.ts | 3 ++- redisinsight/api/test/api/database/constants.ts | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/test/api/database/POST-databases.test.ts b/redisinsight/api/test/api/database/POST-databases.test.ts index ff2f6be0fe..9bcd1c03eb 100644 --- a/redisinsight/api/test/api/database/POST-databases.test.ts +++ b/redisinsight/api/test/api/database/POST-databases.test.ts @@ -22,7 +22,7 @@ const dataSchema = Joi.object({ db: Joi.number().integer().allow(null), username: Joi.string().allow(null), password: Joi.string().allow(null), - new: Joi.boolean().allow(null).required(), + new: Joi.boolean().allow(null), tls: Joi.boolean().allow(null), tlsServername: Joi.string().allow(null), verifyServerCert: Joi.boolean().allow(null), @@ -120,6 +120,7 @@ describe('POST /databases', () => { username: null, password: null, connectionType: constants.STANDALONE, + new: true, }, }); }); @@ -167,6 +168,7 @@ describe('POST /databases', () => { username: null, password: null, connectionType: constants.STANDALONE, + new: true, }, checkFn: ({ body }) => { addedId = body.id; @@ -236,6 +238,7 @@ describe('POST /databases', () => { username: null, password: constants.TEST_REDIS_PASSWORD, connectionType: constants.STANDALONE, + new: true, }, }); @@ -269,6 +272,7 @@ describe('POST /databases', () => { connectionType: constants.STANDALONE, tls: true, verifyServerCert: false, + new: true, }, }); @@ -302,6 +306,7 @@ describe('POST /databases', () => { tls: true, verifyServerCert: true, tlsServername: null, + new: true, }, checkFn: async ({ body }) => { expect(body.caCert.id).to.be.a('string'); diff --git a/redisinsight/api/test/api/database/PUT-databases-id.test.ts b/redisinsight/api/test/api/database/PUT-databases-id.test.ts index c0a98cf03e..53c1827514 100644 --- a/redisinsight/api/test/api/database/PUT-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PUT-databases-id.test.ts @@ -23,7 +23,7 @@ const dataSchema = Joi.object({ db: Joi.number().integer().allow(null), username: Joi.string().allow(null), password: Joi.string().allow(null), - new: Joi.boolean().allow(null).required(), + new: Joi.boolean().allow(null), tls: Joi.boolean().allow(null), tlsServername: Joi.string().allow(null), verifyServerCert: Joi.boolean().allow(null), @@ -185,6 +185,7 @@ describe(`PUT /databases/:id`, () => { newDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID_3); expect(newDatabase).to.contain({ ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection']), + new: true, host: constants.TEST_REDIS_HOST, port: constants.TEST_REDIS_PORT, }); diff --git a/redisinsight/api/test/api/database/constants.ts b/redisinsight/api/test/api/database/constants.ts index c82bc8277e..6cc2ed73fd 100644 --- a/redisinsight/api/test/api/database/constants.ts +++ b/redisinsight/api/test/api/database/constants.ts @@ -13,6 +13,7 @@ export const databaseSchema = Joi.object().keys({ nameFromProvider: Joi.string().allow(null), lastConnection: Joi.string().isoDate().allow(null), provider: Joi.string().valid('LOCALHOST', 'UNKNOWN', 'RE_CLOUD', 'RE_CLUSTER'), + new: Joi.boolean().allow(null).required(), tls: Joi.boolean().allow(null), tlsServername: Joi.string().allow(null), verifyServerCert: Joi.boolean().allow(null), From 501bbe14c6ac2ba2b640d4d25ccc41c25da5148d Mon Sep 17 00:00:00 2001 From: zalenskiSofteq Date: Wed, 7 Dec 2022 00:23:53 +0100 Subject: [PATCH 21/57] #RI-3219 - Integration tests --- redisinsight/api/test/api/database/POST-databases.test.ts | 1 - redisinsight/api/test/api/database/PUT-databases-id.test.ts | 1 - redisinsight/api/test/api/database/constants.ts | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/redisinsight/api/test/api/database/POST-databases.test.ts b/redisinsight/api/test/api/database/POST-databases.test.ts index 9bcd1c03eb..c43600a3dc 100644 --- a/redisinsight/api/test/api/database/POST-databases.test.ts +++ b/redisinsight/api/test/api/database/POST-databases.test.ts @@ -22,7 +22,6 @@ const dataSchema = Joi.object({ db: Joi.number().integer().allow(null), username: Joi.string().allow(null), password: Joi.string().allow(null), - new: Joi.boolean().allow(null), tls: Joi.boolean().allow(null), tlsServername: Joi.string().allow(null), verifyServerCert: Joi.boolean().allow(null), diff --git a/redisinsight/api/test/api/database/PUT-databases-id.test.ts b/redisinsight/api/test/api/database/PUT-databases-id.test.ts index 53c1827514..f25b3e7ab8 100644 --- a/redisinsight/api/test/api/database/PUT-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PUT-databases-id.test.ts @@ -23,7 +23,6 @@ const dataSchema = Joi.object({ db: Joi.number().integer().allow(null), username: Joi.string().allow(null), password: Joi.string().allow(null), - new: Joi.boolean().allow(null), tls: Joi.boolean().allow(null), tlsServername: Joi.string().allow(null), verifyServerCert: Joi.boolean().allow(null), diff --git a/redisinsight/api/test/api/database/constants.ts b/redisinsight/api/test/api/database/constants.ts index 6cc2ed73fd..ec4831d3f7 100644 --- a/redisinsight/api/test/api/database/constants.ts +++ b/redisinsight/api/test/api/database/constants.ts @@ -13,7 +13,7 @@ export const databaseSchema = Joi.object().keys({ nameFromProvider: Joi.string().allow(null), lastConnection: Joi.string().isoDate().allow(null), provider: Joi.string().valid('LOCALHOST', 'UNKNOWN', 'RE_CLOUD', 'RE_CLUSTER'), - new: Joi.boolean().allow(null).required(), + new: Joi.boolean().allow(null), tls: Joi.boolean().allow(null), tlsServername: Joi.string().allow(null), verifyServerCert: Joi.boolean().allow(null), From 0d3b83f4e955e9d871e39db4efb525f0d5982b7c Mon Sep 17 00:00:00 2001 From: Zalenski Egor <63463140+zalenskiSofteq@users.noreply.github.com> Date: Wed, 7 Dec 2022 14:23:49 +0300 Subject: [PATCH 22/57] fix pr comments --- .../components/DatabasesListComponent/DatabasesListWrapper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx index c054534a68..b19b7fe823 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.tsx @@ -190,7 +190,7 @@ const DatabasesListWrapper = ({ position="top" anchorClassName={styles.newStatusAnchor} > -
+
)} Date: Wed, 7 Dec 2022 14:25:06 +0300 Subject: [PATCH 23/57] fix pr comments --- .../DatabasesListComponent/DatabasesListWrapper.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx index 273665373e..3fd6d7c316 100644 --- a/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx @@ -107,7 +107,7 @@ describe('DatabasesListWrapper', () => { const dbIdWithNewIndicator = mockInstances.find(({ new: newState }) => newState)?.id ?? '' const dbIdWithoutNewIndicator = mockInstances.find(({ new: newState }) => !newState)?.id ?? '' - expect(queryByTestId(`database-status-${dbIdWithNewIndicator}`)).toBeInTheDocument() - expect(queryByTestId(`database-status-${dbIdWithoutNewIndicator}`)).not.toBeInTheDocument() + expect(queryByTestId(`database-status-new-${dbIdWithNewIndicator}`)).toBeInTheDocument() + expect(queryByTestId(`database-status-new-${dbIdWithoutNewIndicator}`)).not.toBeInTheDocument() }) }) From 4d3e4ebb70af059ef70a31beae89a25ff84a2f9d Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 7 Dec 2022 17:26:48 +0400 Subject: [PATCH 24/57] #RI-3854 - add features highlighting, add highlighting for import databases --- .../ui/src/components/config/Config.spec.tsx | 98 ++++++++++++++- .../ui/src/components/config/Config.tsx | 33 +++++ .../HighlightedFeature.spec.tsx | 116 ++++++++++++++++++ .../HighlightedFeature.tsx | 75 +++++++++++ .../hightlighted-feature/styles.modules.scss | 25 ++++ .../navigation-menu/NavigationMenu.tsx | 73 +++++++---- .../navigation-menu/styles.module.scss | 9 ++ .../ui/src/constants/featuresHighlighting.tsx | 18 +++ redisinsight/ui/src/constants/pages.ts | 5 +- redisinsight/ui/src/constants/storage.ts | 3 +- .../home/components/HomeHeader/HomeHeader.tsx | 46 +++++-- .../src/slices/app/features-highlighting.ts | 52 ++++++++ redisinsight/ui/src/slices/interfaces/app.ts | 7 ++ redisinsight/ui/src/slices/store.ts | 4 +- .../tests/app/features-highlighting.spec.ts | 93 ++++++++++++++ .../themes/dark_theme/_dark_theme.lazy.scss | 1 + .../themes/dark_theme/_theme_color.scss | 1 + .../themes/light_theme/_light_theme.lazy.scss | 1 + .../themes/light_theme/_theme_color.scss | 1 + redisinsight/ui/src/utils/highlighting.ts | 19 +++ redisinsight/ui/src/utils/test-utils.tsx | 25 +++- .../ui/src/utils/tests/highlighting.spec.ts | 12 ++ 22 files changed, 672 insertions(+), 45 deletions(-) create mode 100644 redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.spec.tsx create mode 100644 redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx create mode 100644 redisinsight/ui/src/components/hightlighted-feature/styles.modules.scss create mode 100644 redisinsight/ui/src/constants/featuresHighlighting.tsx create mode 100644 redisinsight/ui/src/slices/app/features-highlighting.ts create mode 100644 redisinsight/ui/src/slices/tests/app/features-highlighting.spec.ts create mode 100644 redisinsight/ui/src/utils/highlighting.ts create mode 100644 redisinsight/ui/src/utils/tests/highlighting.spec.ts diff --git a/redisinsight/ui/src/components/config/Config.spec.tsx b/redisinsight/ui/src/components/config/Config.spec.tsx index 7f88619a89..5c32e8901f 100644 --- a/redisinsight/ui/src/components/config/Config.spec.tsx +++ b/redisinsight/ui/src/components/config/Config.spec.tsx @@ -1,14 +1,17 @@ import React from 'react' import { cloneDeep } from 'lodash' +import { BuildType } from 'uiSrc/constants/env' +import { localStorageService } from 'uiSrc/services' +import { setFeaturesToHighlight } from 'uiSrc/slices/app/features-highlighting' import { getNotifications } from 'uiSrc/slices/app/notifications' -import { render, mockedStore, cleanup } from 'uiSrc/utils/test-utils' +import { render, mockedStore, cleanup, MOCKED_HIGHLIGHTING_FEATURES } from 'uiSrc/utils/test-utils' import { getUserConfigSettings, setSettingsPopupState, userSettingsSelector, } from 'uiSrc/slices/user/user-settings' -import { getServerInfo } from 'uiSrc/slices/app/info' +import { appServerInfoSelector, getServerInfo } from 'uiSrc/slices/app/info' import { processCliClient } from 'uiSrc/slices/cli/cli-settings' import { getRedisCommands } from 'uiSrc/slices/app/redis-commands' import Config from './Config' @@ -32,6 +35,19 @@ jest.mock('uiSrc/slices/user/user-settings', () => ({ }), })) +jest.mock('uiSrc/slices/app/info', () => ({ + ...jest.requireActual('uiSrc/slices/app/info'), + appServerInfoSelector: jest.fn() +})) + +jest.mock('uiSrc/services', () => ({ + ...jest.requireActual('uiSrc/services'), + localStorageService: { + set: jest.fn(), + get: jest.fn(), + }, +})) + describe('Config', () => { it('should render', () => { render() @@ -75,4 +91,82 @@ describe('Config', () => { ] expect(store.getActions()).toEqual([...afterRenderActions]) }) + + it('should call updateHighlightingFeatures for new user with empty features', () => { + const userSettingsSelectorMock = jest.fn().mockReturnValue({ + config: { + agreements: null, + } + }) + const appServerInfoSelectorMock = jest.fn().mockReturnValue({ + buildType: BuildType.Electron, + appVersion: '2.0.0' + }) + userSettingsSelector.mockImplementation(userSettingsSelectorMock) + appServerInfoSelector.mockImplementation(appServerInfoSelectorMock) + + render() + + expect(store.getActions()) + .toEqual(expect.arrayContaining([setFeaturesToHighlight({ version: '2.0.0', features: [] })])) + }) + + it('should call updateHighlightingFeatures for existing user with proper data', () => { + const userSettingsSelectorMock = jest.fn().mockReturnValue({ + config: { + agreements: {}, + } + }) + const appServerInfoSelectorMock = jest.fn().mockReturnValue({ + buildType: BuildType.Electron, + appVersion: '2.0.0' + }) + userSettingsSelector.mockImplementation(userSettingsSelectorMock) + appServerInfoSelector.mockImplementation(appServerInfoSelectorMock) + + render() + + expect(store.getActions()) + .toEqual(expect.arrayContaining([setFeaturesToHighlight({ version: '2.0.0', features: MOCKED_HIGHLIGHTING_FEATURES })])) + }) + + it('should call updateHighlightingFeatures for existing user with proper data with features from LS', () => { + localStorageService.get = jest.fn().mockReturnValue({ version: '2.0.0', features: ['importDatabases'] }) + const userSettingsSelectorMock = jest.fn().mockReturnValue({ + config: { + agreements: {}, + } + }) + const appServerInfoSelectorMock = jest.fn().mockReturnValue({ + buildType: BuildType.Electron, + appVersion: '2.0.0' + }) + userSettingsSelector.mockImplementation(userSettingsSelectorMock) + appServerInfoSelector.mockImplementation(appServerInfoSelectorMock) + + render() + + expect(store.getActions()) + .toEqual(expect.arrayContaining([setFeaturesToHighlight({ version: '2.0.0', features: ['importDatabases'] })])) + }) + + it('should call updateHighlightingFeatures for existing user with proper data with features from LS for different version', () => { + localStorageService.get = jest.fn().mockReturnValue({ version: '2.0.0', features: ['importDatabases'] }) + const userSettingsSelectorMock = jest.fn().mockReturnValue({ + config: { + agreements: {}, + } + }) + const appServerInfoSelectorMock = jest.fn().mockReturnValue({ + buildType: BuildType.Electron, + appVersion: '2.0.12' + }) + userSettingsSelector.mockImplementation(userSettingsSelectorMock) + appServerInfoSelector.mockImplementation(appServerInfoSelectorMock) + + render() + + expect(store.getActions()) + .toEqual(expect.arrayContaining([setFeaturesToHighlight({ version: '2.0.12', features: MOCKED_HIGHLIGHTING_FEATURES })])) + }) }) diff --git a/redisinsight/ui/src/components/config/Config.tsx b/redisinsight/ui/src/components/config/Config.tsx index 0c2847c696..9d682c9761 100644 --- a/redisinsight/ui/src/components/config/Config.tsx +++ b/redisinsight/ui/src/components/config/Config.tsx @@ -1,6 +1,11 @@ import { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useLocation } from 'react-router-dom' +import { BrowserStorageItem } from 'uiSrc/constants' +import { BuildType } from 'uiSrc/constants/env' +import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' +import { localStorageService } from 'uiSrc/services' +import { setFeaturesToHighlight } from 'uiSrc/slices/app/features-highlighting' import { fetchNotificationsAction } from 'uiSrc/slices/app/notifications' import { @@ -61,8 +66,36 @@ const Config = () => { dispatch(setAnalyticsIdentified(true)) })() } + + featuresHighlight() }, [serverInfo, config]) + const featuresHighlight = () => { + if (serverInfo?.buildType === BuildType.Electron && config) { + // new user, set all features as viewed + if (!config.agreements) { + updateHighlightingFeatures({ version: serverInfo.appVersion, features: [] }) + return + } + + const userFeatures = localStorageService.get(BrowserStorageItem.featuresHighlighting) + + // existing user with the same version of app, get not viewed features from LS + if (userFeatures && userFeatures.version === serverInfo.appVersion) { + dispatch(setFeaturesToHighlight(userFeatures)) + return + } + + // existing user, no any new features viewed (after application update e.g.) + updateHighlightingFeatures({ version: serverInfo.appVersion, features: Object.keys(BUILD_FEATURES) }) + } + } + + const updateHighlightingFeatures = (data: { version: string, features: string[] }) => { + dispatch(setFeaturesToHighlight(data)) + localStorageService.set(BrowserStorageItem.featuresHighlighting, data) + } + const checkSettingsToShowPopup = () => { const specConsents = spec?.agreements const appliedConsents = config?.agreements diff --git a/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.spec.tsx b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.spec.tsx new file mode 100644 index 0000000000..564e2b8075 --- /dev/null +++ b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.spec.tsx @@ -0,0 +1,116 @@ +import { EuiToolTip } from '@elastic/eui' +import { fireEvent } from '@testing-library/react' +import React from 'react' +import { act, render, screen, waitForEuiToolTipVisible } from 'uiSrc/utils/test-utils' + +import HighlightedFeature from './HighlightedFeature' + +const Content = () =>
+describe('HighlightedFeature', () => { + it('should render', () => { + expect(render( + + + + )).toBeTruthy() + }) + + it('should render content', () => { + render( + + + + ) + + expect(screen.getByTestId('some-feature')).toBeInTheDocument() + }) + + it('should render dot highlighting', () => { + render( + + + + ) + + expect(screen.getByTestId('some-feature')).toBeInTheDocument() + expect(screen.getByTestId('dot-highlighting')).toBeInTheDocument() + }) + + it('should not render highlighting', () => { + render( + + + + ) + + expect(screen.getByTestId('some-feature')).toBeInTheDocument() + expect(screen.queryByTestId('dot-highlighting')).not.toBeInTheDocument() + }) + + it('should render tooltip highlighting', async () => { + render( + + + + ) + + expect(screen.getByTestId('some-feature')).toBeInTheDocument() + expect(screen.getByTestId('dot-highlighting')).toBeInTheDocument() + + await act(async () => { + fireEvent.mouseOver(screen.getByTestId('tooltip-highlighting-inner')) + }) + + await waitForEuiToolTipVisible() + + expect(screen.queryByTestId('tooltip-highlighting')).toBeInTheDocument() + expect(screen.queryByTestId('tooltip-highlighting')).toHaveTextContent('title') + expect(screen.queryByTestId('tooltip-highlighting')).toHaveTextContent('content') + }) + + it('should call onClick', () => { + const onClick = jest.fn() + render( + + + + ) + + fireEvent.click(screen.getByTestId('feature-highlighted')) + + expect(onClick).toBeCalled() + }) + + it('should not render second tooltip', async () => { + render( + + + + + + ) + + await act(async () => { + fireEvent.mouseOver(screen.getByTestId('some-feature')) + }) + + await waitForEuiToolTipVisible() + + expect(screen.queryByTestId('tooltip-highlighting')).toBeInTheDocument() + expect(screen.queryByTestId('no-render-tooltip')).not.toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx new file mode 100644 index 0000000000..6ac1a97898 --- /dev/null +++ b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx @@ -0,0 +1,75 @@ +import { EuiToolTip } from '@elastic/eui' +import { ToolTipPositions } from '@elastic/eui/src/components/tool_tip/tool_tip' +import cx from 'classnames' +import React from 'react' +import { FeaturesHighlightingType } from 'uiSrc/constants/featuresHighlighting' + +import styles from './styles.modules.scss' + +export interface Props { + isHighlight?: boolean + children: React.ReactElement + title?: string | React.ReactElement + content?: string | React.ReactElement + type?: FeaturesHighlightingType + transformOnHover?: boolean + onClick?: () => void + wrapperClassName?: string + dotClassName?: string + tooltipPosition?: ToolTipPositions + hideFirstChild?: boolean +} +const HighlightedFeature = (props: Props) => { + const { + isHighlight, + children, + title, + content, + type = 'plain', + transformOnHover, + onClick, + wrapperClassName, + dotClassName, + tooltipPosition, + hideFirstChild + } = props + + const innerContent = hideFirstChild ? children.props.children : children + + const DotHighlighting = () => ( + <> + {innerContent} + + + ) + + const TooltipHighlighting = () => ( + +
+ +
+
+ ) + + if (!isHighlight) return (<>{children}) + + return ( +
onClick?.()} + role="presentation" + data-testid="feature-highlighted" + > + {type === 'plain' && ()} + {type === 'tooltip' && ()} + {type === 'popover' && ()} +
+ ) +} + +export default HighlightedFeature diff --git a/redisinsight/ui/src/components/hightlighted-feature/styles.modules.scss b/redisinsight/ui/src/components/hightlighted-feature/styles.modules.scss new file mode 100644 index 0000000000..645cc23f8a --- /dev/null +++ b/redisinsight/ui/src/components/hightlighted-feature/styles.modules.scss @@ -0,0 +1,25 @@ +.wrapper { + position: relative; + + &:global(.transform-on-hover) { + &:hover { + .dot { + transform: translateY(-1px); + } + } + } + + .dot { + position: absolute; + background: var(--highlightDotColor); + top: -4px; + right: -4px; + z-index: 1; + + width: 12px; + height: 12px; + border-radius: 50%; + + transition: transform 250ms ease-in-out; + } +} diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx index 16f3193d17..00674f8915 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx @@ -17,11 +17,13 @@ import { EuiTitle, EuiToolTip } from '@elastic/eui' +import HighlightedFeature from 'uiSrc/components/hightlighted-feature/HighlightedFeature' import { ANALYTICS_ROUTES } from 'uiSrc/components/main-router/constants/sub-routes' import { PageNames, Pages } from 'uiSrc/constants' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' import { getRouterLinkProps } from 'uiSrc/services' +import { appFeaturePagesHighlightingSelector } from 'uiSrc/slices/app/features-highlighting' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { appElectronInfoSelector, @@ -53,14 +55,15 @@ const browserPath = `/${PageNames.browser}` const pubSubPath = `/${PageNames.pubSub}` interface INavigations { - isActivePage: boolean; - tooltipText: string; - ariaLabel: string; - dataTestId: string; - connectedInstanceId?: string; - onClick: () => void; - getClassName: () => string; - getIconType: () => string; + isActivePage: boolean + pageName: string + tooltipText: string + ariaLabel: string + dataTestId: string + connectedInstanceId?: string + onClick: () => void + getClassName: () => string + getIconType: () => string } const NavigationMenu = () => { @@ -74,6 +77,7 @@ const NavigationMenu = () => { const { id: connectedInstanceId = '' } = useSelector(connectedInstanceSelector) const { isReleaseNotesViewed } = useSelector(appElectronInfoSelector) const { server } = useSelector(appInfoSelector) + const highlightedPages = useSelector(appFeaturePagesHighlightingSelector) useEffect(() => { setActivePage(`/${last(location.pathname.split('/'))}`) @@ -93,6 +97,7 @@ const NavigationMenu = () => { const privateRoutes: INavigations[] = [ { tooltipText: 'Browser', + pageName: PageNames.browser, isActivePage: activePage === browserPath, ariaLabel: 'Browser page button', onClick: () => handleGoPage(Pages.browser(connectedInstanceId)), @@ -107,6 +112,7 @@ const NavigationMenu = () => { }, { tooltipText: 'Workbench', + pageName: PageNames.workbench, ariaLabel: 'Workbench page button', onClick: () => handleGoPage(Pages.workbench(connectedInstanceId)), dataTestId: 'workbench-page-btn', @@ -121,6 +127,7 @@ const NavigationMenu = () => { }, { tooltipText: 'Analysis Tools', + pageName: PageNames.analytics, ariaLabel: 'Analysis Tools', onClick: () => handleGoPage(Pages.analytics(connectedInstanceId)), dataTestId: 'analytics-page-btn', @@ -135,6 +142,7 @@ const NavigationMenu = () => { }, { tooltipText: 'Pub/Sub', + pageName: PageNames.pubSub, ariaLabel: 'Pub/Sub page button', onClick: () => handleGoPage(Pages.pubSub(connectedInstanceId)), dataTestId: 'pub-sub-page-btn', @@ -152,6 +160,7 @@ const NavigationMenu = () => { const publicRoutes: INavigations[] = [ { tooltipText: 'Settings', + pageName: PageNames.settings, ariaLabel: 'Settings page button', onClick: () => handleGoPage(Pages.settings), dataTestId: 'settings-page-btn', @@ -281,7 +290,36 @@ const NavigationMenu = () => { {connectedInstanceId && ( privateRoutes.map((nav) => ( - + + + + + + )) + )} +
+
+ + {HelpMenu()} + {publicRoutes.map((nav) => ( + + { data-testid={nav.dataTestId} /> - )) - )} -
-
- - {HelpMenu()} - {publicRoutes.map((nav) => ( - - - + ))} `/?editInstance=${instanceId}`, redisEnterpriseAutodiscovery: '/redis-enterprise-autodiscovery', - settings: '/settings', + settings: `/${PageNames.settings}`, redisCloud, redisCloudSubscriptions: `${redisCloud}/subscriptions`, redisCloudDatabases: `${redisCloud}/databases`, diff --git a/redisinsight/ui/src/constants/storage.ts b/redisinsight/ui/src/constants/storage.ts index 060adb42c9..acae91b5b4 100644 --- a/redisinsight/ui/src/constants/storage.ts +++ b/redisinsight/ui/src/constants/storage.ts @@ -20,7 +20,8 @@ enum BrowserStorageItem { wbCleanUp = 'wbCleanUp', viewFormat = 'viewFormat', wbGroupMode = 'wbGroupMode', - keyDetailSizes = 'keyDetailSizes' + keyDetailSizes = 'keyDetailSizes', + featuresHighlighting = 'featuresHighlighting' } export default BrowserStorageItem diff --git a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx index 959ffc6da9..de6aa94762 100644 --- a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx @@ -9,9 +9,15 @@ import { EuiToolTip, } from '@elastic/eui' import { isEmpty } from 'lodash' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' import { ImportDatabasesDialog } from 'uiSrc/components' +import HighlightedFeature from 'uiSrc/components/hightlighted-feature/HighlightedFeature' +import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' +import { + appFeaturesToHighlightSelector, + removeFeatureFromHighlighting +} from 'uiSrc/slices/app/features-highlighting' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import HelpLinksMenu from 'uiSrc/pages/home/components/HelpLinksMenu' import PromoLink from 'uiSrc/components/promo-link/PromoLink' @@ -42,6 +48,10 @@ const HomeHeader = ({ onAddInstance, direction, welcomePage = false }: Props) => const [guides, setGuides] = useState([]) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) + const { importDatabases: importDatabasesHighlighting } = useSelector(appFeaturesToHighlightSelector) ?? {} + + const dispatch = useDispatch() + useEffect(() => { if (loading || !data || isEmpty(data)) { return @@ -116,20 +126,30 @@ const HomeHeader = ({ onAddInstance, direction, welcomePage = false }: Props) => ) const ImportDatabasesBtn = () => ( - dispatch(removeFeatureFromHighlighting('importDatabases'))} + transformOnHover + hideFirstChild > - - - - + + + + + ) const Guides = () => ( diff --git a/redisinsight/ui/src/slices/app/features-highlighting.ts b/redisinsight/ui/src/slices/app/features-highlighting.ts new file mode 100644 index 0000000000..9d2d2d37cb --- /dev/null +++ b/redisinsight/ui/src/slices/app/features-highlighting.ts @@ -0,0 +1,52 @@ +import { createSlice } from '@reduxjs/toolkit' +import { remove } from 'lodash' +import { BrowserStorageItem } from 'uiSrc/constants' +import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' +import { localStorageService } from 'uiSrc/services' +import { StateAppFeaturesHighlighting } from 'uiSrc/slices/interfaces' +import { RootState } from 'uiSrc/slices/store' +import { getPagesForFeatures } from 'uiSrc/utils/highlighting' + +export const initialState: StateAppFeaturesHighlighting = { + version: '', + features: [], + pages: {} +} + +const appFeaturesHighlightingSlice = createSlice({ + name: 'appFeaturesHighlighting', + initialState, + reducers: { + setFeaturesInitialState: () => initialState, + setFeaturesToHighlight: (state, { payload }: { payload: { version: string, features: string[] } }) => { + state.features = payload.features + state.version = payload.version + state.pages = getPagesForFeatures(payload.features) + }, + removeFeatureFromHighlighting: (state, { payload }: { payload: string }) => { + remove(state.features, (f) => f === payload) + + const pageName = BUILD_FEATURES[payload].page + if (pageName && pageName in state.pages) { + remove(state.pages[pageName], (f) => f === payload) + } + + const { version, features } = state + localStorageService.set(BrowserStorageItem.featuresHighlighting, { version, features }) + } + } +}) + +export const { + setFeaturesInitialState, + setFeaturesToHighlight, + removeFeatureFromHighlighting +} = appFeaturesHighlightingSlice.actions + +export const appFeatureHighlightingSelector = (state: RootState) => state.app.featuresHighlighting +export const appFeaturesToHighlightSelector = (state: RootState): { [key: string]: boolean } => + state.app.featuresHighlighting.features + .reduce((prev, next) => ({ ...prev, [next]: true }), {}) +export const appFeaturePagesHighlightingSelector = (state: RootState) => state.app.featuresHighlighting.pages + +export default appFeaturesHighlightingSlice.reducer diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index b6ca87b5de..af806062a4 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -141,6 +141,13 @@ export interface StateAppSocketConnection { isConnected: boolean } +export interface StateAppFeaturesHighlighting { + version: string + features: string[] + pages: { + [key: string]: string[] + } +} export enum NotificationType { Global = 'global' } diff --git a/redisinsight/ui/src/slices/store.ts b/redisinsight/ui/src/slices/store.ts index c397cfaa6d..6241bdb18d 100644 --- a/redisinsight/ui/src/slices/store.ts +++ b/redisinsight/ui/src/slices/store.ts @@ -26,6 +26,7 @@ import appContextReducer from './app/context' import appRedisCommandsReducer from './app/redis-commands' import appPluginsReducer from './app/plugins' import appsSocketConnectionReducer from './app/socket-connection' +import appFeaturesHighlightingReducer from './app/features-highlighting' import workbenchResultsReducer from './workbench/wb-results' import workbenchGuidesReducer from './workbench/wb-guides' import workbenchTutorialsReducer from './workbench/wb-tutorials' @@ -46,7 +47,8 @@ export const rootReducer = combineReducers({ context: appContextReducer, redisCommands: appRedisCommandsReducer, plugins: appPluginsReducer, - socketConnection: appsSocketConnectionReducer + socketConnection: appsSocketConnectionReducer, + featuresHighlighting: appFeaturesHighlightingReducer }), connections: combineReducers({ instances: instancesReducer, diff --git a/redisinsight/ui/src/slices/tests/app/features-highlighting.spec.ts b/redisinsight/ui/src/slices/tests/app/features-highlighting.spec.ts new file mode 100644 index 0000000000..5c2b8355a5 --- /dev/null +++ b/redisinsight/ui/src/slices/tests/app/features-highlighting.spec.ts @@ -0,0 +1,93 @@ +import { cloneDeep } from 'lodash' +import reducer, { + initialState, + setFeaturesInitialState, + appFeatureHighlightingSelector, + setFeaturesToHighlight, + removeFeatureFromHighlighting +} from 'uiSrc/slices/app/features-highlighting' +import { + cleanup, + initialStateDefault, + MOCKED_HIGHLIGHTING_FEATURES, + mockedStore +} from 'uiSrc/utils/test-utils' + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +const mockFeatures = MOCKED_HIGHLIGHTING_FEATURES +describe('slices', () => { + describe('setFeaturesInitialState', () => { + it('should properly set initial state', () => { + const nextState = reducer(initialState, setFeaturesInitialState()) + const rootState = Object.assign(initialStateDefault, { + app: { featuresHighlighting: nextState }, + }) + expect(appFeatureHighlightingSelector(rootState)).toEqual(initialState) + }) + }) + + describe('setFeaturesToHighlight', () => { + it('should properly set features to highlight', () => { + const payload = { + features: mockFeatures, + version: '2.0.0' + } + const state = { + ...initialState, + features: payload.features, + version: payload.version, + pages: { + browser: payload.features + } + } + + // Act + const nextState = reducer(initialState, setFeaturesToHighlight(payload)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + app: { featuresHighlighting: nextState }, + }) + + expect(appFeatureHighlightingSelector(rootState)).toEqual(state) + }) + }) + + describe('removeFeatureFromHighlighting', () => { + it('should properly remove feature to highlight', () => { + const prevState = { + ...initialState, + features: mockFeatures, + version: '2.0.0', + pages: { + browser: mockFeatures + } + } + + const payload = mockFeatures[0] + const state = { + ...prevState, + features: [mockFeatures[1]], + pages: { + browser: [mockFeatures[1]] + } + } + + // Act + const nextState = reducer(prevState, removeFeatureFromHighlighting(payload)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + app: { featuresHighlighting: nextState }, + }) + + expect(appFeatureHighlightingSelector(rootState)).toEqual(state) + }) + }) +}) diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 2dee6e4865..8671c02ef9 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -130,6 +130,7 @@ --overlayPromoNYColor: #{$overlayPromoNYColor}; --monacoBgColor: #{$monacoBgColor}; + --highlightDotColor: #{$highlightDotColor}; // KeyTypes --typeHashColor: #{$typeHashColor}; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index bcfb32c4f2..863cecce12 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -90,6 +90,7 @@ $commandGroupBadgeColor: #3f4b5f; $overlayPromoNYColor: #0000001a; $monacoBgColor: #111; +$highlightDotColor: #2BBBB2; // Types colors $typeHashColor: #364cff; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index a9fbfa450e..baf19dbb73 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -132,6 +132,7 @@ --overlayPromoNYColor: #{$overlayPromoNYColor}; --monacoBgColor: #{$monacoBgColor}; + --highlightDotColor: #{$highlightDotColor}; // KeyTypes --typeHashColor: #{$typeHashColor}; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index e925a43fe6..844d962be1 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -87,6 +87,7 @@ $callOutBackgroundColor: #e9edfa; $overlayPromoNYColor: #ffffff1a; $monacoBgColor: #f0f2f7; +$highlightDotColor: #2BBBB2; // Types colors $typeHashColor: #cdddf8; diff --git a/redisinsight/ui/src/utils/highlighting.ts b/redisinsight/ui/src/utils/highlighting.ts new file mode 100644 index 0000000000..95479d32cf --- /dev/null +++ b/redisinsight/ui/src/utils/highlighting.ts @@ -0,0 +1,19 @@ +import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' + +export const getPagesForFeatures = (features: string[] = []) => { + const result: { [key: string]: string[] } = {} + features.forEach((f) => { + if (f in BUILD_FEATURES) { + const pageName = BUILD_FEATURES[f].page + if (!pageName) return + + if (result[pageName]) { + result[pageName] = [...result[pageName], f] + } else { + result[pageName] = [f] + } + } + }) + + return result +} diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index a27a8356ca..4917988491 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -29,6 +29,7 @@ import { initialState as initialStateAppContext } from 'uiSrc/slices/app/context import { initialState as initialStateAppRedisCommands } from 'uiSrc/slices/app/redis-commands' import { initialState as initialStateAppPluginsReducer } from 'uiSrc/slices/app/plugins' import { initialState as initialStateAppSocketConnectionReducer } from 'uiSrc/slices/app/socket-connection' +import { initialState as initialStateAppFeaturesHighlightingReducer } from 'uiSrc/slices/app/features-highlighting' import { initialState as initialStateCliSettings } from 'uiSrc/slices/cli/cli-settings' import { initialState as initialStateCliOutput } from 'uiSrc/slices/cli/cli-output' import { initialState as initialStateMonitor } from 'uiSrc/slices/cli/monitor' @@ -61,7 +62,8 @@ const initialStateDefault: RootState = { context: cloneDeep(initialStateAppContext), redisCommands: cloneDeep(initialStateAppRedisCommands), plugins: cloneDeep(initialStateAppPluginsReducer), - socketConnection: cloneDeep(initialStateAppSocketConnectionReducer) + socketConnection: cloneDeep(initialStateAppSocketConnectionReducer), + featuresHighlighting: cloneDeep(initialStateAppFeaturesHighlightingReducer) }, connections: { instances: cloneDeep(initialStateInstances), @@ -214,6 +216,27 @@ jest.mock( }) ) +export const MOCKED_HIGHLIGHTING_FEATURES = ['importDatabases', 'anotherFeature'] +jest.mock( + 'uiSrc/constants/featuresHighlighting', + () => ({ + BUILD_FEATURES: { + importDatabases: { + type: 'tooltip', + title: 'Import Database Connections', + content: 'Import your database connections from other Redis UIs', + page: 'browser' + }, + anotherFeature: { + type: 'tooltip', + title: 'Import Database Connections', + content: 'Import your database connections from other Redis UIs', + page: 'browser' + } + } + }) +) + export const localStorageMock = { getItem: jest.fn(), setItem: jest.fn(), diff --git a/redisinsight/ui/src/utils/tests/highlighting.spec.ts b/redisinsight/ui/src/utils/tests/highlighting.spec.ts new file mode 100644 index 0000000000..df999bd68a --- /dev/null +++ b/redisinsight/ui/src/utils/tests/highlighting.spec.ts @@ -0,0 +1,12 @@ +import { getPagesForFeatures } from 'uiSrc/utils/highlighting' +import { MOCKED_HIGHLIGHTING_FEATURES } from 'uiSrc/utils/test-utils' + +describe('getPagesForFeatures', () => { + it('should return proper pages for features', () => { + expect(getPagesForFeatures()).toEqual({}) + expect(getPagesForFeatures([])).toEqual({}) + expect(getPagesForFeatures(['a'])).toEqual({}) + expect(getPagesForFeatures(['importDatabases'])).toEqual({ browser: ['importDatabases'] }) + expect(getPagesForFeatures(MOCKED_HIGHLIGHTING_FEATURES)).toEqual({ browser: MOCKED_HIGHLIGHTING_FEATURES }) + }) +}) From 990cb90c827c537fad0a51aaa9501a68311008ec Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 7 Dec 2022 17:36:12 +0400 Subject: [PATCH 25/57] #RI-3854 - remove page from build_features config --- redisinsight/ui/src/constants/featuresHighlighting.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/redisinsight/ui/src/constants/featuresHighlighting.tsx b/redisinsight/ui/src/constants/featuresHighlighting.tsx index 0b203843ef..6dd60756de 100644 --- a/redisinsight/ui/src/constants/featuresHighlighting.tsx +++ b/redisinsight/ui/src/constants/featuresHighlighting.tsx @@ -12,7 +12,6 @@ export const BUILD_FEATURES: { [key: string]: BuildHighlightingFeature } = { importDatabases: { type: 'tooltip', title: 'Import Database Connections', - content: 'Import your database connections from other Redis UIs', - page: 'settings' + content: 'Import your database connections from other Redis UIs' } } From cb816e1cdeaa356b9e2635d8a3c8d035430bce68 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Thu, 8 Dec 2022 14:19:59 +0400 Subject: [PATCH 26/57] #RI-3854 - fix pr comments --- redisinsight/ui/src/components/config/Config.tsx | 2 +- .../hightlighted-feature/HighlightedFeature.spec.tsx | 4 ++-- .../components/hightlighted-feature/HighlightedFeature.tsx | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/redisinsight/ui/src/components/config/Config.tsx b/redisinsight/ui/src/components/config/Config.tsx index 9d682c9761..62152a4dfd 100644 --- a/redisinsight/ui/src/components/config/Config.tsx +++ b/redisinsight/ui/src/components/config/Config.tsx @@ -81,7 +81,7 @@ const Config = () => { const userFeatures = localStorageService.get(BrowserStorageItem.featuresHighlighting) // existing user with the same version of app, get not viewed features from LS - if (userFeatures && userFeatures.version === serverInfo.appVersion) { + if (userFeatures?.version === serverInfo.appVersion) { dispatch(setFeaturesToHighlight(userFeatures)) return } diff --git a/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.spec.tsx b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.spec.tsx index 564e2b8075..135531463d 100644 --- a/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.spec.tsx +++ b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.spec.tsx @@ -76,12 +76,12 @@ describe('HighlightedFeature', () => { it('should call onClick', () => { const onClick = jest.fn() render( - + ) - fireEvent.click(screen.getByTestId('feature-highlighted')) + fireEvent.click(screen.getByTestId('feature-highlighted-feature')) expect(onClick).toBeCalled() }) diff --git a/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx index 6ac1a97898..f26c1f5532 100644 --- a/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx +++ b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx @@ -18,6 +18,7 @@ export interface Props { dotClassName?: string tooltipPosition?: ToolTipPositions hideFirstChild?: boolean + dataTestPostfix?: string } const HighlightedFeature = (props: Props) => { const { @@ -31,7 +32,8 @@ const HighlightedFeature = (props: Props) => { wrapperClassName, dotClassName, tooltipPosition, - hideFirstChild + hideFirstChild, + dataTestPostfix = '' } = props const innerContent = hideFirstChild ? children.props.children : children @@ -63,7 +65,7 @@ const HighlightedFeature = (props: Props) => { className={cx(styles.wrapper, wrapperClassName, { 'transform-on-hover': transformOnHover })} onClick={() => onClick?.()} role="presentation" - data-testid="feature-highlighted" + data-testid={`feature-highlighted-${dataTestPostfix}`} > {type === 'plain' && ()} {type === 'tooltip' && ()} From b83fd5f22e98c5ae0cb4b469a8c25741e31d3890 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 8 Dec 2022 14:07:17 +0200 Subject: [PATCH 27/57] #RI-3902 rework errors to return --- .../api/src/__mocks__/database-import.ts | 10 +++++-- .../database-import.analytics.ts | 19 ++++++++++-- .../database-import.service.spec.ts | 6 +--- .../database-import.service.ts | 29 ++++++++++++------- .../dto/database-import.response.ts | 18 ++++++++---- .../POST-databases-import.test.ts | 5 +++- 6 files changed, 58 insertions(+), 29 deletions(-) diff --git a/redisinsight/api/src/__mocks__/database-import.ts b/redisinsight/api/src/__mocks__/database-import.ts index 210c90e1e4..f94da3ceb5 100644 --- a/redisinsight/api/src/__mocks__/database-import.ts +++ b/redisinsight/api/src/__mocks__/database-import.ts @@ -24,7 +24,7 @@ export const mockDatabaseImportResultFail = { status: DatabaseImportStatus.Fail, host: mockDatabase.host, port: mockDatabase.port, - error: new BadRequestException(), + errors: [new BadRequestException()], }; export const mockDatabaseImportResponse = Object.assign(new DatabaseImportResponse(), { @@ -34,10 +34,14 @@ export const mockDatabaseImportResponse = Object.assign(new DatabaseImportRespon index: index + 3, })), partial: [], - fail: [new ValidationException([]), new BadRequestException(), new ForbiddenException()].map((error, index) => ({ + fail: [ + new ValidationException('Bad request'), + new BadRequestException(), + new ForbiddenException(), + ].map((error, index) => ({ ...mockDatabaseImportResultFail, index, - error, + errors: [error], })), }); diff --git a/redisinsight/api/src/modules/database-import/database-import.analytics.ts b/redisinsight/api/src/modules/database-import/database-import.analytics.ts index 6d4894d448..b343b92df6 100644 --- a/redisinsight/api/src/modules/database-import/database-import.analytics.ts +++ b/redisinsight/api/src/modules/database-import/database-import.analytics.ts @@ -1,8 +1,9 @@ +import { uniq } from 'lodash'; import { Injectable } from '@nestjs/common'; import { TelemetryBaseService } from 'src/modules/analytics/telemetry.base.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { TelemetryEvents } from 'src/constants'; -import { DatabaseImportResponse } from 'src/modules/database-import/dto/database-import.response'; +import { DatabaseImportResponse, DatabaseImportResult } from 'src/modules/database-import/dto/database-import.response'; @Injectable() export class DatabaseImportAnalytics extends TelemetryBaseService { @@ -25,7 +26,7 @@ export class DatabaseImportAnalytics extends TelemetryBaseService { TelemetryEvents.DatabaseImportFailed, { failed: importResult.fail.length, - errors: importResult.fail.map((res) => (res?.error?.constructor?.name || 'UncaughtError')), + errors: DatabaseImportAnalytics.getUniqueErrorNamesFromResults(importResult.fail), }, ); } @@ -35,7 +36,7 @@ export class DatabaseImportAnalytics extends TelemetryBaseService { TelemetryEvents.DatabaseImportPartiallySucceeded, { partially: importResult.partial.length, - errors: importResult.partial.map((res) => (res?.error?.constructor?.name || 'UncaughtError')), + errors: DatabaseImportAnalytics.getUniqueErrorNamesFromResults(importResult.partial), }, ); } @@ -49,4 +50,16 @@ export class DatabaseImportAnalytics extends TelemetryBaseService { }, ); } + + static getUniqueErrorNamesFromResults(results: DatabaseImportResult[]) { + return uniq( + [].concat( + ...results.map( + (res) => (res?.errors || []).map( + (error) => error?.constructor?.name || 'UncaughtError', + ), + ), + ), + ); + } } diff --git a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts index 1c38ba3435..56773f0890 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts @@ -59,10 +59,7 @@ describe('DatabaseImportService', () => { it('should import databases from json', async () => { const response = await service.import(mockDatabaseImportFile); - expect(response).toEqual({ - ...mockDatabaseImportResponse, - errors: undefined, // errors omitted from response - }); + expect(response).toEqual(mockDatabaseImportResponse); expect(analytics.sendImportResults).toHaveBeenCalledWith(mockDatabaseImportResponse); }); @@ -75,7 +72,6 @@ describe('DatabaseImportService', () => { expect(response).toEqual({ ...mockDatabaseImportResponse, - errors: undefined, // errors omitted from response }); expect(analytics.sendImportResults).toHaveBeenCalledWith(mockDatabaseImportResponse); }); diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index b551e79b28..234bd6e6ba 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -1,4 +1,6 @@ -import { HttpException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { + HttpException, Injectable, InternalServerErrorException, Logger, +} from '@nestjs/common'; import { get, isArray, set } from 'lodash'; import { Database } from 'src/modules/database/models/database'; import { plainToClass } from 'class-transformer'; @@ -163,27 +165,32 @@ export class DatabaseImportService { port: database.port, }; } catch (e) { - let error = e; + let errors = [e]; if (isArray(e)) { - [error] = e; + errors = e; } - if (error instanceof ValidationError) { - error = new ValidationException(Object.values(error?.constraints || {}) || 'Bad request'); - } + errors = errors.map((error) => { + if (error instanceof ValidationError) { + const messages = Object.values(error?.constraints || {}); + return new ValidationException(messages[messages.length - 1] || 'Bad request'); + } - if (!(error instanceof HttpException)) { - error = new InternalServerErrorException(error?.message); - } + if (!(error instanceof HttpException)) { + return new InternalServerErrorException(error?.message); + } + + return error; + }); - this.logger.warn(`Unable to import database: ${error?.constructor?.name || 'UncaughtError'}`, error); + this.logger.warn(`Unable to import database: ${errors[0]?.constructor?.name || 'UncaughtError'}`, errors[0]); return { index, status: DatabaseImportStatus.Fail, host: item?.host, port: item?.port, - error, + errors, }; } } diff --git a/redisinsight/api/src/modules/database-import/dto/database-import.response.ts b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts index 535c8d7869..79f29f659b 100644 --- a/redisinsight/api/src/modules/database-import/dto/database-import.response.ts +++ b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts @@ -1,4 +1,4 @@ -import { isArray, isString, isNumber } from 'lodash'; +import { isString, isNumber } from 'lodash'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Expose, Transform, Type } from 'class-transformer'; @@ -49,13 +49,19 @@ export class DatabaseImportResult { return undefined; } - if (e?.response?.message) { - return isArray(e.response.message) ? e.response.message[e.response.message.length - 1] : e.response.message; - } + return e.map((error) => { + if (error?.response) { + return error.response; + } - return e?.message || 'Unhandled Error'; + return { + statusCode: 500, + message: error?.message || 'Unhandled Error', + error: 'Unhandled Error', + }; + }); }, { toPlainOnly: true }) - error?: Error; + errors?: Error[]; } export class DatabaseImportResponse { diff --git a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts index f848c06bc0..015ad07ab6 100644 --- a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts +++ b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts @@ -73,7 +73,10 @@ describe('POST /databases/import', () => { expect(body.fail.length).to.eq(1); expect(body.fail[0].status).to.eq('fail'); expect(body.fail[0].index).to.eq(0); - expect(body.fail[0].error).to.be.a('string'); + expect(body.fail[0].errors.length).to.eq(1); + expect(body.fail[0].errors[0].message).to.be.a('string'); + expect(body.fail[0].errors[0].statusCode).to.eq(400); + expect(body.fail[0].errors[0].error).to.eq('Bad Request'); if (body.fail[0].host) { expect(body.fail[0].host).to.be.a('string'); } From d7e7024dc172a8ab75548ea76268645662e3e8eb Mon Sep 17 00:00:00 2001 From: nmammadli Date: Thu, 8 Dec 2022 16:51:52 +0100 Subject: [PATCH 28/57] Initial Commit --- tests/e2e/pageObjects/browser-page.ts | 24 +++++++++----- .../database/clone-databases.e2e.ts | 20 +++++++----- .../smoke/database/add-standalone-db.e2e.ts | 31 +++++++++++++------ 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index 5259a3e495..37a218a6f4 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -22,6 +22,8 @@ export class BrowserPage { //*The following categories are ordered alphabetically (Alerts, Buttons, Checkboxes, etc.). //------------------------------------------------------------------------------------------- //BUTTONS + streamDeleteButton = Selector(`[data-testid="stream-delete-btn"]`); + myRedisDbIcon = Selector('[data-testid=my-redis-db-icon]'); deleteKeyButton = Selector('[data-testid=delete-key-btn]'); confirmDeleteKeyButton = Selector('[data-testid=delete-key-confirm-btn]'); editKeyTTLButton = Selector('[data-testid=edit-ttl-btn]'); @@ -98,8 +100,8 @@ export class BrowserPage { workbenchLinkButton = Selector('[data-test-subj=workbench-page-btn]'); cancelStreamGroupBtn = Selector('[data-testid=cancel-stream-groups-btn]'); submitTooltipBtn = Selector('[data-testid=submit-tooltip-btn]'); - patternModeBtn = Selector('[data-testid=search-mode-pattern-btn]'); - redisearchModeBtn = Selector('[data-testid=search-mode-redisearch-btn]'); + patternModeBtn = Selector('[data-testid=search-mode-pattern-btn]'); + redisearchModeBtn = Selector('[data-testid=search-mode-redisearch-btn]'); //CONTAINERS streamGroupsContainer = Selector('[data-testid=stream-groups-container]'); streamConsumersContainer = Selector('[data-testid=stream-consumers-container]'); @@ -136,7 +138,7 @@ export class BrowserPage { createIndexBtn = Selector('[data-testid=create-index-btn]'); cancelIndexCreationBtn = Selector('[data-testid=create-index-cancel-btn]'); confirmIndexCreationBtn = Selector('[data-testid=create-index-btn]'); - resizeTrigger = Selector('[data-testid^=resize-trigger-]'); + resizeTrigger = Selector('[data-testid^=resize-trigger-]'); //TABS streamTabGroups = Selector('[data-testid=stream-tab-Groups]'); streamTabConsumers = Selector('[data-testid=stream-tab-Consumers]'); @@ -445,7 +447,7 @@ export class BrowserPage { async addEntryToStream(field: string, value: string, entryId?: string): Promise { await t .click(this.addNewStreamEntry) - // Specify field, value and add new entry + // Specify field, value and add new entry .typeText(this.streamField, field, { replace: true, paste: true }) .typeText(this.streamValue, value, { replace: true, paste: true }); if (entryId !== undefined) { @@ -453,7 +455,7 @@ export class BrowserPage { } await t .click(this.saveElementButton) - // Validate that new entry is added + // Validate that new entry is added .expect(this.streamEntriesContainer.textContent).contains(field, 'Field parameter not correct') .expect(this.streamEntriesContainer.textContent).contains(value, 'Value parameter not correct'); } @@ -566,8 +568,8 @@ export class BrowserPage { * Delete keys by their Names * @param keyNames The names of the key array */ - async deleteKeysByNames(keyNames: string[]): Promise { - for(const name of keyNames) { + async deleteKeysByNames(keyNames: string[]): Promise { + for (const name of keyNames) { await this.deleteKeyByName(name); } } @@ -1012,6 +1014,14 @@ export class BrowserPage { .click(this.selectIndexDdn) .click(option); } + + /** + * Verify database status is visible + */ + async verifyDatabaseStatusIsVisible(): Promise { + await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible). + ok("Database status is not visible"); + } } /** diff --git a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts index 290c4712ca..20e140dec5 100644 --- a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts @@ -1,5 +1,5 @@ import { rte } from '../../../helpers/constants'; -import { AddRedisDatabasePage, MyRedisDatabasePage } from '../../../pageObjects'; +import { AddRedisDatabasePage, BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; import { commonUrl, ossClusterConfig, ossSentinelConfig, ossStandaloneConfig } from '../../../helpers/conf'; import { acceptLicenseTerms, clickOnEditDatabaseByName } from '../../../helpers/database'; import { @@ -16,17 +16,18 @@ const addRedisDatabasePage = new AddRedisDatabasePage(); const myRedisDatabasePage = new MyRedisDatabasePage(); const common = new Common(); const newOssDatabaseAlias = 'cloned oss cluster'; +const browserPage = new BrowserPage(); -fixture `Clone databases` +fixture`Clone databases` .meta({ type: 'critical_path' }) .page(commonUrl); test - .before(async() => { + .before(async () => { await acceptLicenseTerms(); await addNewStandaloneDatabaseApi(ossStandaloneConfig); await common.reloadPage(); }) - .after(async() => { + .after(async () => { // Delete databases const dbNumber = await myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).count; for (let i = 0; i < dbNumber; i++) { @@ -37,6 +38,7 @@ test await clickOnEditDatabaseByName(ossStandaloneConfig.databaseName); // Verify that user can cancel the Clone by clicking the “Cancel” or the “x” button await t.click(addRedisDatabasePage.cloneDatabaseButton); + await browserPage.verifyDatabaseStatusIsVisible(); await t.click(addRedisDatabasePage.cancelButton); await t.expect(myRedisDatabasePage.editAliasButton.withText('Clone ').exists).notOk('Clone panel is still displayed', { timeout: 2000 }); await clickOnEditDatabaseByName(ossStandaloneConfig.databaseName); @@ -53,12 +55,12 @@ test await t.expect(myRedisDatabasePage.dbNameList.withExactText(ossStandaloneConfig.databaseName).count).eql(2, 'DB was not cloned'); }); test - .before(async() => { + .before(async () => { await acceptLicenseTerms(); await addNewOSSClusterDatabaseApi(ossClusterConfig); await common.reloadPage(); }) - .after(async() => { + .after(async () => { // Delete database await deleteOSSClusterDatabaseApi(ossClusterConfig); await myRedisDatabasePage.deleteDatabaseByName(newOssDatabaseAlias); @@ -66,6 +68,7 @@ test .meta({ rte: rte.ossCluster })('Verify that user can clone OSS Cluster', async t => { await clickOnEditDatabaseByName(ossClusterConfig.ossClusterDatabaseName); await t.click(addRedisDatabasePage.cloneDatabaseButton); + await browserPage.verifyDatabaseStatusIsVisible(); await t .expect(myRedisDatabasePage.editAliasButton.withText('Clone ').exists).ok('Clone panel is not displayed') .expect(addRedisDatabasePage.portInput.getAttribute('value')).eql(ossClusterConfig.ossClusterPort, 'Wrong port value') @@ -77,13 +80,13 @@ test await t.expect(myRedisDatabasePage.dbNameList.withExactText(ossClusterConfig.ossClusterDatabaseName).exists).ok('Original DB is not displayed'); }); test - .before(async() => { + .before(async () => { await acceptLicenseTerms(); // Add Sentinel databases await discoverSentinelDatabaseApi(ossSentinelConfig); await common.reloadPage(); }) - .after(async() => { + .after(async () => { // Delete all primary groups const sentinelCopy = ossSentinelConfig; sentinelCopy.masters.push(ossSentinelConfig.masters[1]); @@ -94,6 +97,7 @@ test .meta({ rte: rte.sentinel })('Verify that user can clone Sentinel', async t => { await clickOnEditDatabaseByName(ossSentinelConfig.name[1]); await t.click(addRedisDatabasePage.cloneDatabaseButton); + await browserPage.verifyDatabaseStatusIsVisible(); // Verify that for Sentinel Host and Port fields are replaced with editable Primary Group Name field await t .expect(myRedisDatabasePage.editAliasButton.withText('Clone ').exists).ok('Clone panel is not displayed') diff --git a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts index 0c31265b2b..f14bdf37eb 100644 --- a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts @@ -6,37 +6,48 @@ import { redisEnterpriseClusterConfig } from '../../../helpers/conf'; import { env, rte } from '../../../helpers/constants'; +import { Selector, t } from 'testcafe'; +import { BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; -fixture `Add database` +const browserPage = new BrowserPage() + +fixture`Add database` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async() => { + .beforeEach(async () => { await acceptLicenseTerms(); }); test .meta({ rte: rte.standalone }) - .after(async() => { + .after(async () => { await deleteDatabase(ossStandaloneConfig.databaseName); - })('Verify that user can add Standalone Database', async() => { + })('Verify that user can add Standalone Database', async () => { + const myRedisDatabasePage = new MyRedisDatabasePage(); await addNewStandaloneDatabase(ossStandaloneConfig); + await browserPage.verifyDatabaseStatusIsVisible(); + await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); + await t.click(browserPage.myRedisDbIcon); + await browserPage.verifyDatabaseStatusIsVisible(); }); test .meta({ rte: rte.reCluster }) - .after(async() => { + .after(async () => { await deleteDatabase(redisEnterpriseClusterConfig.databaseName); - })('Verify that user can add database from RE Cluster via auto-discover flow', async() => { + })('Verify that user can add database from RE Cluster via auto-discover flow', async () => { await addNewREClusterDatabase(redisEnterpriseClusterConfig); + await browserPage.verifyDatabaseStatusIsVisible(); }); test - .meta({ env: env.web, rte: rte.ossCluster}) - .after(async() => { + .meta({ env: env.web, rte: rte.ossCluster }) + .after(async () => { await deleteDatabase(ossClusterConfig.ossClusterDatabaseName); - })('Verify that user can add OSS Cluster DB', async() => { + })('Verify that user can add OSS Cluster DB', async () => { await addOSSClusterDatabase(ossClusterConfig); + await browserPage.verifyDatabaseStatusIsVisible(); }); //skiped until the RE Cloud connection is implemented test.skip - .meta({ rte: rte.reCloud })('Verify that user can add database from RE Cloud via auto-discover flow', async() => { + .meta({ rte: rte.reCloud })('Verify that user can add database from RE Cloud via auto-discover flow', async () => { //TODO: add api keys from env await addNewRECloudDatabase('', ''); }); From f96392bfce5c964a5fbdb40ad1ff0c906ab40693 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 12 Dec 2022 13:09:01 +0200 Subject: [PATCH 29/57] #RI-3728 - Base BE implementation. Import certs by plain values #RI-3684 - Base BE implementation. Import certs from files --- .../common/utils/certificate-import.util.ts | 7 + redisinsight/api/src/common/utils/index.ts | 1 + .../api/src/constants/error-messages.ts | 4 + .../certificate-import.service.ts | 216 ++++++++++++++++++ .../database-import/database-import.module.ts | 2 + .../database-import.service.ts | 44 +++- .../dto/import.database.dto.ts | 34 ++- .../database-import/exceptions/index.ts | 4 + .../invalid-ca-certificate-body.exception.ts | 14 ++ .../invalid-certificate-name.exception.ts | 14 ++ ...valid-client-certificate-body.exception.ts | 14 ++ .../invalid-client-private-key.exception.ts | 14 ++ 12 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 redisinsight/api/src/common/utils/certificate-import.util.ts create mode 100644 redisinsight/api/src/common/utils/index.ts create mode 100644 redisinsight/api/src/modules/database-import/certificate-import.service.ts create mode 100644 redisinsight/api/src/modules/database-import/exceptions/invalid-ca-certificate-body.exception.ts create mode 100644 redisinsight/api/src/modules/database-import/exceptions/invalid-certificate-name.exception.ts create mode 100644 redisinsight/api/src/modules/database-import/exceptions/invalid-client-certificate-body.exception.ts create mode 100644 redisinsight/api/src/modules/database-import/exceptions/invalid-client-private-key.exception.ts diff --git a/redisinsight/api/src/common/utils/certificate-import.util.ts b/redisinsight/api/src/common/utils/certificate-import.util.ts new file mode 100644 index 0000000000..77b5fe3312 --- /dev/null +++ b/redisinsight/api/src/common/utils/certificate-import.util.ts @@ -0,0 +1,7 @@ +import { parse } from 'path'; +import { readFileSync } from 'fs'; + +export const isValidPemCertificate = (cert: string): boolean => cert.startsWith('-----BEGIN CERTIFICATE-----'); +export const isValidPemPrivateKey = (cert: string): boolean => cert.startsWith('-----BEGIN PRIVATE KEY-----'); +export const getPemBodyFromFileSync = (path: string): string => readFileSync(path).toString('utf8'); +export const getCertNameFromFilename = (path: string): string => parse(path).name; diff --git a/redisinsight/api/src/common/utils/index.ts b/redisinsight/api/src/common/utils/index.ts new file mode 100644 index 0000000000..ee0efba08b --- /dev/null +++ b/redisinsight/api/src/common/utils/index.ts @@ -0,0 +1 @@ +export * from './certificate-import.util'; diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index afd6aa4340..ff4d26de4a 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -17,6 +17,10 @@ export default { INCORRECT_CREDENTIALS: (url) => `Could not connect to ${url}, please check the Username or Password.`, CA_CERT_EXIST: 'This ca certificate name is already in use.', + INVALID_CA_BODY: 'Invalid CA body', + INVALID_CERTIFICATE_BODY: 'Invalid certificate body', + INVALID_PRIVATE_KEY: 'Invalid private key', + CERTIFICATE_NAME_IS_NOT_DEFINED: 'Certificate name is not defined', CLIENT_CERT_EXIST: 'This client certificate name is already in use.', INVALID_CERTIFICATE_ID: 'Invalid certificate id.', SENTINEL_MASTER_NAME_REQUIRED: 'Sentinel master name must be specified.', diff --git a/redisinsight/api/src/modules/database-import/certificate-import.service.ts b/redisinsight/api/src/modules/database-import/certificate-import.service.ts new file mode 100644 index 0000000000..a5e30f212a --- /dev/null +++ b/redisinsight/api/src/modules/database-import/certificate-import.service.ts @@ -0,0 +1,216 @@ +import { Injectable } from '@nestjs/common'; +import { CaCertificate } from 'src/modules/certificate/models/ca-certificate'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity'; +import { Repository } from 'typeorm'; +import { EncryptionService } from 'src/modules/encryption/encryption.service'; +import { ModelEncryptor } from 'src/modules/encryption/model.encryptor'; +import { ClientCertificate } from 'src/modules/certificate/models/client-certificate'; +import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity'; +import { classToClass } from 'src/utils'; +import { + getCertNameFromFilename, + getPemBodyFromFileSync, + isValidPemCertificate, + isValidPemPrivateKey, +} from 'src/common/utils'; +import { + InvalidCaCertificateBodyException, InvalidCertificateNameException, + InvalidClientCertificateBodyException, InvalidClientPrivateKeyException, +} from 'src/modules/database-import/exceptions'; + +@Injectable() +export class CertificateImportService { + private caCertEncryptor: ModelEncryptor; + + private clientCertEncryptor: ModelEncryptor; + + constructor( + @InjectRepository(CaCertificateEntity) + private readonly caCertRepository: Repository, + @InjectRepository(ClientCertificateEntity) + private readonly clientCertRepository: Repository, + private readonly encryptionService: EncryptionService, + ) { + this.caCertEncryptor = new ModelEncryptor(encryptionService, ['certificate']); + this.clientCertEncryptor = new ModelEncryptor(encryptionService, ['certificate', 'key']); + } + + /** + * Validate data + prepare CA certificate to be imported along with new database + * @param cert + */ + async processCaCertificate(cert: Partial): Promise { + let toImport: Partial = { + certificate: null, + name: cert.name, + }; + + if (isValidPemCertificate(cert.certificate)) { + toImport.certificate = cert.certificate; + } else { + try { + toImport.certificate = getPemBodyFromFileSync(cert.certificate); + toImport.name = getCertNameFromFilename(cert.certificate); + } catch (e) { + // ignore error + toImport = null; + } + } + + if (!toImport?.certificate || !isValidPemCertificate(toImport.certificate)) { + throw new InvalidCaCertificateBodyException(); + } + + if (!toImport?.name) { + throw new InvalidCertificateNameException(); + } + + return this.prepareCaCertificateForImport(toImport); + } + + /** + * Use existing certificate if found + * Generate unique name for new certificate + * @param cert + * @private + */ + private async prepareCaCertificateForImport(cert: Partial): Promise { + const encryptedModel = await this.caCertEncryptor.encryptEntity(cert as CaCertificate); + const existing = await this.caCertRepository.createQueryBuilder('c') + .select('c.id') + .where({ certificate: cert.certificate }) + .orWhere({ certificate: encryptedModel.certificate }) + .getOne(); + + if (existing) { + return existing; + } + + const name = await CertificateImportService.determineAvailableName( + cert.name, + this.caCertRepository, + ); + + return classToClass(CaCertificate, { + ...cert, + name, + }); + } + + /** + * Validate data + prepare CA certificate to be imported along with new database + * @param cert + */ + async processClientCertificate(cert: Partial): Promise { + const toImport: Partial = { + certificate: null, + key: null, + name: cert.name, + }; + + if (isValidPemCertificate(cert.certificate)) { + toImport.certificate = cert.certificate; + } else { + try { + toImport.certificate = getPemBodyFromFileSync(cert.certificate); + toImport.name = getCertNameFromFilename(cert.certificate); + } catch (e) { + // ignore error + toImport.certificate = null; + toImport.name = null; + } + } + + if (isValidPemPrivateKey(cert.key)) { + toImport.key = cert.key; + } else { + try { + toImport.key = getPemBodyFromFileSync(cert.key); + } catch (e) { + // ignore error + toImport.key = null; + } + } + + if (!toImport?.certificate || !isValidPemCertificate(toImport.certificate)) { + throw new InvalidClientCertificateBodyException(); + } + + if (!toImport?.key || !isValidPemPrivateKey(toImport.key)) { + throw new InvalidClientPrivateKeyException(); + } + + if (!toImport?.name) { + throw new InvalidCertificateNameException(); + } + + return this.prepareClientCertificateForImport(toImport); + } + + /** + * Use existing certificate if found + * Generate unique name for new certificate + * @param cert + * @private + */ + private async prepareClientCertificateForImport(cert: Partial): Promise { + const encryptedModel = await this.clientCertEncryptor.encryptEntity(cert as ClientCertificate); + const existing = await this.clientCertRepository.createQueryBuilder('c') + .select('c.id') + .where({ + certificate: cert.certificate, + key: cert.key, + }) + .orWhere({ + certificate: encryptedModel.certificate, + key: encryptedModel.key, + }) + .getOne(); + + if (existing) { + return existing; + } + + const name = await CertificateImportService.determineAvailableName( + cert.name, + this.clientCertRepository, + ); + + return classToClass(ClientCertificate, { + ...cert, + name, + }); + } + + /** + * Find available name for certificate using such pattern "{N}_{name}" + * @param originalName + * @param repository + */ + static async determineAvailableName(originalName: string, repository: Repository): Promise { + let index = 0; + + // temporary solution + // investigate how to make working "regexp" for sqlite + // https://github.com/kriasoft/node-sqlite/issues/55 + // https://www.sqlite.org/c3ref/create_function.html + while (true) { + let name = originalName; + + if (index) { + name = `${index}_${name}`; + } + + if (!await repository + .createQueryBuilder('c') + .where({ name }) + .select(['c.id']) + .getOne()) { + return name; + } + + index += 1; + } + } +} diff --git a/redisinsight/api/src/modules/database-import/database-import.module.ts b/redisinsight/api/src/modules/database-import/database-import.module.ts index ae7e036f30..b4814589dc 100644 --- a/redisinsight/api/src/modules/database-import/database-import.module.ts +++ b/redisinsight/api/src/modules/database-import/database-import.module.ts @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common'; import { DatabaseImportController } from 'src/modules/database-import/database-import.controller'; import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; import { DatabaseImportAnalytics } from 'src/modules/database-import/database-import.analytics'; +import { CertificateImportService } from 'src/modules/database-import/certificate-import.service'; @Module({ controllers: [DatabaseImportController], providers: [ DatabaseImportService, + CertificateImportService, DatabaseImportAnalytics, ], }) diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index 234bd6e6ba..bd5965d3a5 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -21,6 +21,7 @@ import { UnableToParseDatabaseImportFileException, } from 'src/modules/database-import/exceptions'; import { ValidationException } from 'src/common/exceptions'; +import { CertificateImportService } from 'src/modules/database-import/certificate-import.service'; @Injectable() export class DatabaseImportService { @@ -36,9 +37,17 @@ export class DatabaseImportService { ['port', ['port']], ['db', ['db']], ['isCluster', ['cluster']], + ['tls', ['tls', 'ssl']], + ['tlsServername', ['tlsServername', 'sni_name', 'sni_server_name']], + ['tlsCaName', ['caCert.name']], + ['tlsCaCert', ['caCert.certificate', 'sslOptions.ca', 'ssl_ca_cert_path']], + ['tlsClientName', ['clientCert.name']], + ['tlsClientCert', ['clientCert.certificate', 'sslOptions.cert', 'ssl_local_cert_path']], + ['tlsClientKey', ['clientCert.key', 'sslOptions.key', 'ssl_private_key_path']], ]; constructor( + private readonly certificateImportService: CertificateImportService, private readonly databaseRepository: DatabaseRepository, private readonly analytics: DatabaseImportAnalytics, ) {} @@ -115,6 +124,8 @@ export class DatabaseImportService { */ private async createDatabase(item: any, index: number): Promise { try { + let status = DatabaseImportStatus.Success; + const errors = []; const data: any = {}; this.fieldsMapSchema.forEach(([field, paths]) => { @@ -140,6 +151,33 @@ export class DatabaseImportService { data.connectionType = ConnectionType.STANDALONE; } + if (data?.tlsCaCert) { + try { + data.tls = true; + data.caCert = await this.certificateImportService.processCaCertificate({ + certificate: data.tlsCaCert, + name: data?.tlsCaName, + }); + } catch (e) { + status = DatabaseImportStatus.Partial; + errors.push(e); + } + } + + if (data?.tlsClientCert || data?.tlsClientKey) { + try { + data.tls = true; + data.clientCert = await this.certificateImportService.processClientCertificate({ + certificate: data.tlsClientCert, + key: data.tlsClientKey, + name: data?.tlsClientName, + }); + } catch (e) { + status = DatabaseImportStatus.Partial; + errors.push(e); + } + } + const dto = plainToClass( ImportDatabaseDto, // additionally replace empty strings ("") with null @@ -148,6 +186,9 @@ export class DatabaseImportService { acc[key] = data[key] === '' ? null : data[key]; return acc; }, {}), + { + groups: ['security'], + }, ); await this.validator.validateOrReject(dto, { @@ -160,9 +201,10 @@ export class DatabaseImportService { return { index, - status: DatabaseImportStatus.Success, + status, host: database.host, port: database.port, + errors: errors?.length ? errors : undefined, }; } catch (e) { let errors = [e]; diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index de696bcafc..46215ef872 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -1,13 +1,19 @@ -import { PickType } from '@nestjs/swagger'; +import { ApiPropertyOptional, getSchemaPath, PickType } from '@nestjs/swagger'; import { Database } from 'src/modules/database/models/database'; import { Expose, Type } from 'class-transformer'; import { - IsInt, IsNotEmpty, Max, Min, + IsInt, IsNotEmpty, IsNotEmptyObject, IsOptional, Max, Min, ValidateNested, } from 'class-validator'; +import { caCertTransformer } from 'src/modules/certificate/transformers/ca-cert.transformer'; +import { CreateCaCertificateDto } from 'src/modules/certificate/dto/create.ca-certificate.dto'; +import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certificate.dto'; +import { CreateClientCertificateDto } from 'src/modules/certificate/dto/create.client-certificate.dto'; +import { clientCertTransformer } from 'src/modules/certificate/transformers/client-cert.transformer'; +import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; export class ImportDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', - 'connectionType', + 'connectionType', 'tls', 'verifyServerCert', ] as const) { @Expose() @IsNotEmpty() @@ -16,4 +22,26 @@ export class ImportDatabaseDto extends PickType(Database, [ @Min(0) @Max(65535) port: number; + + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(caCertTransformer) + @ValidateNested() + caCert?: CreateCaCertificateDto | UseCaCertificateDto; + + @ApiPropertyOptional({ + description: 'Client Certificate', + oneOf: [ + { $ref: getSchemaPath(CreateClientCertificateDto) }, + { $ref: getSchemaPath(UseCaCertificateDto) }, + ], + }) + @Expose() + @IsOptional() + @IsNotEmptyObject() + @Type(clientCertTransformer) + @ValidateNested() + clientCert?: CreateClientCertificateDto | UseClientCertificateDto; + } diff --git a/redisinsight/api/src/modules/database-import/exceptions/index.ts b/redisinsight/api/src/modules/database-import/exceptions/index.ts index d62d9a0259..2ba9fdfb68 100644 --- a/redisinsight/api/src/modules/database-import/exceptions/index.ts +++ b/redisinsight/api/src/modules/database-import/exceptions/index.ts @@ -1,3 +1,7 @@ +export * from './invalid-ca-certificate-body.exception'; +export * from './invalid-client-certificate-body.exception'; +export * from './invalid-client-private-key.exception'; +export * from './invalid-certificate-name.exception'; export * from './size-limit-exceeded-database-import-file.exception'; export * from './no-database-import-file-provided.exception'; export * from './unable-to-parse-database-import-file.exception'; diff --git a/redisinsight/api/src/modules/database-import/exceptions/invalid-ca-certificate-body.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/invalid-ca-certificate-body.exception.ts new file mode 100644 index 0000000000..fcffc334f4 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/invalid-ca-certificate-body.exception.ts @@ -0,0 +1,14 @@ +import { HttpException } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class InvalidCaCertificateBodyException extends HttpException { + constructor(message: string = ERROR_MESSAGES.INVALID_CA_BODY) { + const response = { + message, + statusCode: 400, + error: 'Invalid Ca Certificate Body', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/src/modules/database-import/exceptions/invalid-certificate-name.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/invalid-certificate-name.exception.ts new file mode 100644 index 0000000000..4826f69c67 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/invalid-certificate-name.exception.ts @@ -0,0 +1,14 @@ +import { HttpException } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class InvalidCertificateNameException extends HttpException { + constructor(message: string = ERROR_MESSAGES.CERTIFICATE_NAME_IS_NOT_DEFINED) { + const response = { + message, + statusCode: 400, + error: 'Invalid Certificate Name', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/src/modules/database-import/exceptions/invalid-client-certificate-body.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/invalid-client-certificate-body.exception.ts new file mode 100644 index 0000000000..6caa948b84 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/invalid-client-certificate-body.exception.ts @@ -0,0 +1,14 @@ +import { HttpException } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class InvalidClientCertificateBodyException extends HttpException { + constructor(message: string = ERROR_MESSAGES.INVALID_CERTIFICATE_BODY) { + const response = { + message, + statusCode: 400, + error: 'Invalid Client Certificate Body', + }; + + super(response, 400); + } +} diff --git a/redisinsight/api/src/modules/database-import/exceptions/invalid-client-private-key.exception.ts b/redisinsight/api/src/modules/database-import/exceptions/invalid-client-private-key.exception.ts new file mode 100644 index 0000000000..1b372ab8b3 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/invalid-client-private-key.exception.ts @@ -0,0 +1,14 @@ +import { HttpException } from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class InvalidClientPrivateKeyException extends HttpException { + constructor(message: string = ERROR_MESSAGES.INVALID_PRIVATE_KEY) { + const response = { + message, + statusCode: 400, + error: 'Invalid Client Private Key', + }; + + super(response, 400); + } +} From f3fe413a2e33aeec4c7f3948157b73a7313886ae Mon Sep 17 00:00:00 2001 From: nmammadli Date: Tue, 13 Dec 2022 14:25:59 +0100 Subject: [PATCH 30/57] Implement feedback from comments --- tests/e2e/pageObjects/browser-page.ts | 14 +++++++++++--- .../database/clone-databases.e2e.ts | 3 +++ .../smoke/database/add-standalone-db.e2e.ts | 18 +++++++++++------- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index 37a218a6f4..b9257a1a2e 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -22,7 +22,7 @@ export class BrowserPage { //*The following categories are ordered alphabetically (Alerts, Buttons, Checkboxes, etc.). //------------------------------------------------------------------------------------------- //BUTTONS - streamDeleteButton = Selector(`[data-testid="stream-delete-btn"]`); + streamDeleteButton = Selector('[data-testid=stream-delete-btn]'); myRedisDbIcon = Selector('[data-testid=my-redis-db-icon]'); deleteKeyButton = Selector('[data-testid=delete-key-btn]'); confirmDeleteKeyButton = Selector('[data-testid=delete-key-confirm-btn]'); @@ -1019,8 +1019,16 @@ export class BrowserPage { * Verify database status is visible */ async verifyDatabaseStatusIsVisible(): Promise { - await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible). - ok("Database status is not visible"); + await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible) + .ok("Database status is not visible"); + } + + /** + * Verify database status is not visible + */ + async verifyDatabaseStatusIsNotVisible(): Promise { + await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible) + .notOk("Database status is still visible"); } } diff --git a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts index 20e140dec5..b5dc7e1df2 100644 --- a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts @@ -38,6 +38,7 @@ test await clickOnEditDatabaseByName(ossStandaloneConfig.databaseName); // Verify that user can cancel the Clone by clicking the “Cancel” or the “x” button await t.click(addRedisDatabasePage.cloneDatabaseButton); + // New connections indicator await browserPage.verifyDatabaseStatusIsVisible(); await t.click(addRedisDatabasePage.cancelButton); await t.expect(myRedisDatabasePage.editAliasButton.withText('Clone ').exists).notOk('Clone panel is still displayed', { timeout: 2000 }); @@ -68,6 +69,7 @@ test .meta({ rte: rte.ossCluster })('Verify that user can clone OSS Cluster', async t => { await clickOnEditDatabaseByName(ossClusterConfig.ossClusterDatabaseName); await t.click(addRedisDatabasePage.cloneDatabaseButton); + // New connections indicator await browserPage.verifyDatabaseStatusIsVisible(); await t .expect(myRedisDatabasePage.editAliasButton.withText('Clone ').exists).ok('Clone panel is not displayed') @@ -97,6 +99,7 @@ test .meta({ rte: rte.sentinel })('Verify that user can clone Sentinel', async t => { await clickOnEditDatabaseByName(ossSentinelConfig.name[1]); await t.click(addRedisDatabasePage.cloneDatabaseButton); + // New connections indicator await browserPage.verifyDatabaseStatusIsVisible(); // Verify that for Sentinel Host and Port fields are replaced with editable Primary Group Name field await t diff --git a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts index f14bdf37eb..50044f4e4e 100644 --- a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts @@ -6,10 +6,11 @@ import { redisEnterpriseClusterConfig } from '../../../helpers/conf'; import { env, rte } from '../../../helpers/constants'; -import { Selector, t } from 'testcafe'; +import { t } from 'testcafe'; import { BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; -const browserPage = new BrowserPage() +const browserPage = new BrowserPage(); +const myRedisDatabasePage = new MyRedisDatabasePage(); fixture`Add database` .meta({ type: 'smoke' }) @@ -18,16 +19,17 @@ fixture`Add database` await acceptLicenseTerms(); }); test + .only .meta({ rte: rte.standalone }) .after(async () => { await deleteDatabase(ossStandaloneConfig.databaseName); })('Verify that user can add Standalone Database', async () => { - const myRedisDatabasePage = new MyRedisDatabasePage(); await addNewStandaloneDatabase(ossStandaloneConfig); await browserPage.verifyDatabaseStatusIsVisible(); await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); await t.click(browserPage.myRedisDbIcon); - await browserPage.verifyDatabaseStatusIsVisible(); + // New connections indicator + await browserPage.verifyDatabaseStatusIsNotVisible(); }); test .meta({ rte: rte.reCluster }) @@ -35,6 +37,7 @@ test await deleteDatabase(redisEnterpriseClusterConfig.databaseName); })('Verify that user can add database from RE Cluster via auto-discover flow', async () => { await addNewREClusterDatabase(redisEnterpriseClusterConfig); + // New connections indicator await browserPage.verifyDatabaseStatusIsVisible(); }); test @@ -43,11 +46,12 @@ test await deleteDatabase(ossClusterConfig.ossClusterDatabaseName); })('Verify that user can add OSS Cluster DB', async () => { await addOSSClusterDatabase(ossClusterConfig); + // New connections indicator await browserPage.verifyDatabaseStatusIsVisible(); }); -//skiped until the RE Cloud connection is implemented -test.skip + +test .meta({ rte: rte.reCloud })('Verify that user can add database from RE Cloud via auto-discover flow', async () => { - //TODO: add api keys from env + // New connections indicator await addNewRECloudDatabase('', ''); }); From f4c46eda52ab59fdcc8075a7fefe41a0dc42e488 Mon Sep 17 00:00:00 2001 From: nmammadli Date: Tue, 13 Dec 2022 14:48:25 +0100 Subject: [PATCH 31/57] After eslint --- .../smoke/database/add-standalone-db.e2e.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts index 50044f4e4e..772522361a 100644 --- a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts @@ -1,3 +1,4 @@ +import { t } from 'testcafe'; import { addNewStandaloneDatabase, addNewREClusterDatabase, addNewRECloudDatabase, addOSSClusterDatabase, acceptLicenseTerms, deleteDatabase } from '../../../helpers/database'; import { commonUrl, @@ -6,7 +7,6 @@ import { redisEnterpriseClusterConfig } from '../../../helpers/conf'; import { env, rte } from '../../../helpers/constants'; -import { t } from 'testcafe'; import { BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; const browserPage = new BrowserPage(); @@ -15,15 +15,15 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); fixture`Add database` .meta({ type: 'smoke' }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTerms(); }); test .only .meta({ rte: rte.standalone }) - .after(async () => { + .after(async() => { await deleteDatabase(ossStandaloneConfig.databaseName); - })('Verify that user can add Standalone Database', async () => { + })('Verify that user can add Standalone Database', async() => { await addNewStandaloneDatabase(ossStandaloneConfig); await browserPage.verifyDatabaseStatusIsVisible(); await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); @@ -33,25 +33,25 @@ test }); test .meta({ rte: rte.reCluster }) - .after(async () => { + .after(async() => { await deleteDatabase(redisEnterpriseClusterConfig.databaseName); - })('Verify that user can add database from RE Cluster via auto-discover flow', async () => { - await addNewREClusterDatabase(redisEnterpriseClusterConfig); + })('Verify that user can add database from RE Cluster via auto-discover flow', async() => { // New connections indicator + await addNewREClusterDatabase(redisEnterpriseClusterConfig); await browserPage.verifyDatabaseStatusIsVisible(); }); test .meta({ env: env.web, rte: rte.ossCluster }) - .after(async () => { + .after(async() => { await deleteDatabase(ossClusterConfig.ossClusterDatabaseName); - })('Verify that user can add OSS Cluster DB', async () => { - await addOSSClusterDatabase(ossClusterConfig); + })('Verify that user can add OSS Cluster DB', async() => { // New connections indicator + await addOSSClusterDatabase(ossClusterConfig); await browserPage.verifyDatabaseStatusIsVisible(); }); test - .meta({ rte: rte.reCloud })('Verify that user can add database from RE Cloud via auto-discover flow', async () => { + .meta({ rte: rte.reCloud })('Verify that user can add database from RE Cloud via auto-discover flow', async() => { // New connections indicator await addNewRECloudDatabase('', ''); }); From 7b89ce16e821399eda5f3957e75314993650f3aa Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Tue, 13 Dec 2022 17:19:42 +0300 Subject: [PATCH 32/57] #RI-3903 - add imports results --- .../ImportDatabasesDialog.tsx | 33 +++---- .../components/ResultsLog/ResultsLog.tsx | 87 +++++++++++++++++++ .../components/ResultsLog/index.ts | 3 + .../components/ResultsLog/styles.module.scss | 85 ++++++++++++++++++ .../components/TableResult/TableResult.tsx | 74 ++++++++++++++++ .../components/TableResult/index.ts | 3 + .../components/TableResult/styles.module.scss | 29 +++++++ .../styles.module.scss | 3 +- .../ui/src/slices/interfaces/instances.ts | 33 ++++++- .../ui/src/styles/components/_table.scss | 31 ++++++- redisinsight/ui/src/telemetry/events.ts | 1 + 11 files changed, 356 insertions(+), 26 deletions(-) create mode 100644 redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.tsx create mode 100644 redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/index.ts create mode 100644 redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/styles.module.scss create mode 100644 redisinsight/ui/src/components/import-databases-dialog/components/TableResult/TableResult.tsx create mode 100644 redisinsight/ui/src/components/import-databases-dialog/components/TableResult/index.ts create mode 100644 redisinsight/ui/src/components/import-databases-dialog/components/TableResult/styles.module.scss diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx index 8215352ca8..9737f4e9dc 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx @@ -2,8 +2,7 @@ import { EuiButton, EuiFilePicker, EuiFlexGroup, - EuiFlexItem, - EuiIcon, + EuiFlexItem, EuiIcon, EuiLoadingSpinner, EuiModal, EuiModalBody, @@ -24,6 +23,7 @@ import { } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { Nullable } from 'uiSrc/utils' +import ResultsLog from './components/ResultsLog' import styles from './styles.module.scss' @@ -50,7 +50,9 @@ const ImportDatabasesDialog = ({ onClose }: Props) => { const handleOnClose = () => { dispatch(resetImportInstances()) - data?.success && dispatch(fetchInstancesAction()) + if (data?.success?.length || data?.partial?.length) { + dispatch(fetchInstancesAction()) + } onClose(!data) } @@ -72,17 +74,17 @@ const ImportDatabasesDialog = ({ onClose }: Props) => { - - Import Database Connections + + {(!data && !error) ? 'Import Database Connections' : 'Import results'} - + {isShowForm && ( - <> + { File should not exceed {MAX_MB_FILE} MB )} - + )} {loading && ( @@ -107,17 +109,8 @@ const ImportDatabasesDialog = ({ onClose }: Props) => { Uploading...
)} - - {data && data.success !== 0 && ( -
- - - Successfully added {data.success} of {data.total} database connections - -
- )} - - {(data?.success === 0 || error) && ( + {data && ()} + {error && (
@@ -129,7 +122,7 @@ const ImportDatabasesDialog = ({ onClose }: Props) => { - {data && data.success !== 0 && ( + {data && ( { + const [openedNav, setOpenedNav] = useState('') + + const onToggle = (length: number = 0, isOpen: boolean, name: string) => { + if (length === 0) return + setOpenedNav(isOpen ? name : '') + + if (isOpen) { + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_LOG_VIEWED, + eventData: { + length, + name + } + }) + } + } + + const CollapsibleNavTitle = ({ title, length = 0 }: { title: string, length: number }) => ( +
+ {title} + {length} +
+ ) + + const getNavGroupState = (name: ResultsStatus) => (openedNav === name ? 'open' : 'closed') + + return ( + <> + } + className={cx(styles.collapsibleNav, ResultsStatus.Success, { [styles.disabled]: !data?.success?.length })} + isCollapsible + initialIsOpen={false} + onToggle={(isOpen) => onToggle(data?.success?.length, isOpen, ResultsStatus.Success)} + forceState={getNavGroupState(ResultsStatus.Success)} + data-testid={`success-results-${getNavGroupState(ResultsStatus.Success)}`} + > + + + } + className={cx(styles.collapsibleNav, ResultsStatus.Partial, { [styles.disabled]: !data?.partial?.length })} + isCollapsible + initialIsOpen={false} + onToggle={(isOpen) => onToggle(data?.partial?.length, isOpen, ResultsStatus.Partial)} + forceState={getNavGroupState(ResultsStatus.Partial)} + data-testid={`partial-results-${getNavGroupState(ResultsStatus.Partial)}`} + > + + + } + className={cx(styles.collapsibleNav, ResultsStatus.Failed, { [styles.disabled]: !data?.fail?.length })} + isCollapsible + initialIsOpen={false} + onToggle={(isOpen) => onToggle(data?.fail?.length, isOpen, ResultsStatus.Failed)} + forceState={getNavGroupState(ResultsStatus.Failed)} + data-testid={`failed-results-${getNavGroupState(ResultsStatus.Failed)}`} + > + + + + ) +} + +export default ResultsLog diff --git a/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/index.ts b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/index.ts new file mode 100644 index 0000000000..1eb9fdbb50 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/index.ts @@ -0,0 +1,3 @@ +import ResultsLog from './ResultsLog' + +export default ResultsLog diff --git a/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/styles.module.scss b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/styles.module.scss new file mode 100644 index 0000000000..6b15697083 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/styles.module.scss @@ -0,0 +1,85 @@ +.collapsibleNav { + width: 100%; + margin-top: 5px !important; + position: relative; + + &.disabled { + :global { + .euiAccordion__button { + cursor: auto; + pointer-events: none; + } + .euiAccordion__iconWrapper { + display: none; + } + } + } + + &:global(.euiAccordion-isOpen) { + :global { + .euiAccordion__triggerWrapper { + border-radius: 0; + border-color: transparent; + } + } + } + + &:global(.success) { + &:before { + background-color: #13A450; + } + } + + &:global(.partial) { + &:before { + background-color: #9D6901; + } + } + + &:global(.failed) { + &:before { + background-color: #AD0017; + } + } + + &:before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + + border-radius: 4px 0 0 4px; + + z-index: 2; + } + + :global { + .euiCollapsibleNavGroup__title { + font-size: 12px !important; + font-weight: 400 !important; + color: var(--euiTextSubduedColor) !important; + } + .euiAccordion__triggerWrapper { + background-color: var(--euiColorEmptyShade); + padding: 12px 24px 12px 32px !important; + border-radius: 4px; + border: 1px solid #3D3D3D; + } + .euiCollapsibleNavGroup__children { + padding: 0 !important; + } + + .euiIEFlexWrapFix { + flex-grow: 1; + padding-right: 4px; + } + } +} + +.collapsibleNavTitle { + display: flex; + justify-content: space-between; + align-items: center; +} diff --git a/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/TableResult.tsx b/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/TableResult.tsx new file mode 100644 index 0000000000..5fd5b112d4 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/TableResult.tsx @@ -0,0 +1,74 @@ +import { EuiBasicTableColumn, EuiInMemoryTable } from '@elastic/eui' +import cx from 'classnames' +import React from 'react' + +import { ErrorImportResult } from 'uiSrc/slices/interfaces' +import { Maybe } from 'uiSrc/utils' + +import styles from './styles.module.scss' + +export interface DataImportResult { + index: number + status: string + errors?: Array + host?: string + port?: number +} +export interface Props { + data: Array +} + +const TableResult = (props: Props) => { + const { data } = props + + const ErrorResult = ({ errors }: { errors: string[] }) => ( +
    + {errors.map((message, i) => ( +
  • {message}
  • + ))} +
+ ) + + const columns: EuiBasicTableColumn[] = [ + { + name: '#', + field: 'index', + width: '4%', + render: (index: number) => (({index})) + }, + { + name: 'Host:Port', + field: 'host', + width: '25%', + truncateText: true, + render: (_host, { host, port, index }) => (
{host}:{port}
) + }, + { + name: 'Result', + field: 'errors', + width: '25%', + render: (errors: Maybe, { index }) => ( +
+ {errors ? ( e.message)} />) : 'Successful'} +
+ ) + } + ] + + if (data?.length === 0) return null + + return ( +
+ +
+ ) +} + +export default TableResult diff --git a/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/index.ts b/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/index.ts new file mode 100644 index 0000000000..9e622e5f46 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/index.ts @@ -0,0 +1,3 @@ +import TableResult from './TableResult' + +export default TableResult diff --git a/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/styles.module.scss b/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/styles.module.scss new file mode 100644 index 0000000000..dad38b82f2 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/styles.module.scss @@ -0,0 +1,29 @@ +@import "@elastic/eui/src/global_styling/mixins/helpers"; +@import "@elastic/eui/src/components/table/mixins"; +@import "@elastic/eui/src/global_styling/index"; + +.tableWrapper { + max-height: 200px; + @include euiScrollBar; + + overflow: auto; + + .table { + :global { + .euiTableHeaderCell { + background-color: var(--browserTableRowEven); + } + + .euiTableRowCell { + vertical-align: top !important; + } + + .euiTableCellContent { + white-space: normal !important; + font-size: 12px !important; + padding: 8px 14px; + } + } + } + +} diff --git a/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss index 3f240cd6bf..8fda92df89 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss +++ b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss @@ -1,11 +1,12 @@ .modal { background: var(--euiColorLightestShade) !important; min-width: 500px !important; + max-width: 700px !important; min-height: 270px !important; :global { .euiModalHeader { - padding: 4px 42px 42px 30px; + padding: 4px 42px 20px 30px; } .euiModalBody__overflow { diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 538900f412..61234c9b6a 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -285,13 +285,38 @@ export interface InitialStateInstances { importInstances: { loading: boolean error: string - data: Nullable<{ - success: number, - total: number - }> + data: Nullable } } +export interface ErrorImportResult { + statusCode: number + message: string + error: string +} + +export interface ImportDatabasesData { + fail: Array + partial: Array + success: Array + total: number +} + +export interface FailedImportStatusResult { + host?: string + port?: number + index: number + errors: Array + status: string +} + +export interface SuccessImportStatusResult { + host: string + port: number + index: number + status: string +} + export interface InitialStateEditedInstances { loading: boolean error: string diff --git a/redisinsight/ui/src/styles/components/_table.scss b/redisinsight/ui/src/styles/components/_table.scss index b5173ee337..9b6aba7b3d 100644 --- a/redisinsight/ui/src/styles/components/_table.scss +++ b/redisinsight/ui/src/styles/components/_table.scss @@ -62,7 +62,7 @@ table { overflow: hidden; } - &.noHeaderBorders { + &.noHeaderBorders, &.noBorders { .euiTableRow { &:not(:first-child) { .euiTableRowCell { @@ -81,6 +81,35 @@ table { } } + &.noBorders { + .euiTableCaption { + height: 0; + } + .euiTableRowCell { + border-top: 0; + } + .euiTableRow:nth-child(odd) { + td { + &:first-child { + border-left: 1px solid var(--euiColorEmptyShade); + } + &:last-child { + border-right: 1px solid var(--euiColorEmptyShade); + } + } + } + .euiTableRow:nth-child(even) { + td { + &:first-child { + border-left: 1px solid var(--browserTableRowEven); + } + &:last-child { + border-right: 1px solid var(--browserTableRowEven); + } + } + } + } + &.stickyHeader { .euiTableHeaderCell { position: sticky; diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 1185656b4c..435ae4db64 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -30,6 +30,7 @@ export enum TelemetryEvent { CONFIG_DATABASES_REDIS_IMPORT_SUBMITTED = 'CONFIG_DATABASES_REDIS_IMPORT_SUBMITTED', CONFIG_DATABASES_REDIS_IMPORT_CANCELLED = 'CONFIG_DATABASES_REDIS_IMPORT_CANCELLED', CONFIG_DATABASES_REDIS_IMPORT_CLICKED = 'CONFIG_DATABASES_REDIS_IMPORT_CLICKED', + CONFIG_DATABASES_REDIS_IMPORT_LOG_VIEWED = 'CONFIG_DATABASES_REDIS_IMPORT_LOG_VIEWED', BUILD_FROM_SOURCE_CLICKED = 'BUILD_FROM_SOURCE_CLICKED', BUILD_USING_DOCKER_CLICKED = 'BUILD_USING_DOCKER_CLICKED', From 0ce880f1ecb66798046e906e7099f4aaa0147e67 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 13 Dec 2022 16:31:48 +0100 Subject: [PATCH 33/57] update for re cloud --- .../smoke/database/add-standalone-db.e2e.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts index 772522361a..eedc1724e2 100644 --- a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts @@ -1,10 +1,18 @@ import { t } from 'testcafe'; -import { addNewStandaloneDatabase, addNewREClusterDatabase, addNewRECloudDatabase, addOSSClusterDatabase, acceptLicenseTerms, deleteDatabase } from '../../../helpers/database'; +import { + addNewStandaloneDatabase, + addNewREClusterDatabase, + addOSSClusterDatabase, + acceptLicenseTerms, + deleteDatabase, + acceptLicenseTermsAndAddRECloudDatabase +} from '../../../helpers/database'; import { commonUrl, ossStandaloneConfig, ossClusterConfig, - redisEnterpriseClusterConfig + redisEnterpriseClusterConfig, + cloudDatabaseConfig } from '../../../helpers/conf'; import { env, rte } from '../../../helpers/constants'; import { BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; @@ -12,14 +20,13 @@ import { BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; const browserPage = new BrowserPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); -fixture`Add database` +fixture `Add database` .meta({ type: 'smoke' }) .page(commonUrl) .beforeEach(async() => { await acceptLicenseTerms(); }); test - .only .meta({ rte: rte.standalone }) .after(async() => { await deleteDatabase(ossStandaloneConfig.databaseName); @@ -53,5 +60,6 @@ test test .meta({ rte: rte.reCloud })('Verify that user can add database from RE Cloud via auto-discover flow', async() => { // New connections indicator - await addNewRECloudDatabase('', ''); + await acceptLicenseTermsAndAddRECloudDatabase(cloudDatabaseConfig); + await browserPage.verifyDatabaseStatusIsVisible(); }); From e87480dedff3536b73c435caedc707d58ea1a3c1 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 13 Dec 2022 16:55:15 +0100 Subject: [PATCH 34/57] moved verifyDatabaseStatusIsVisible() from browser page object to my redis databases also added comments with test names --- tests/e2e/pageObjects/browser-page.ts | 16 --------------- .../pageObjects/my-redis-databases-page.ts | 16 +++++++++++++++ .../database/clone-databases.e2e.ts | 16 ++++++++++----- .../smoke/database/add-standalone-db.e2e.ts | 20 ++++++++++--------- .../database/connecting-to-the-db.e2e.ts | 4 ++++ 5 files changed, 42 insertions(+), 30 deletions(-) diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index b9257a1a2e..2caf1cf679 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -1014,22 +1014,6 @@ export class BrowserPage { .click(this.selectIndexDdn) .click(option); } - - /** - * Verify database status is visible - */ - async verifyDatabaseStatusIsVisible(): Promise { - await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible) - .ok("Database status is not visible"); - } - - /** - * Verify database status is not visible - */ - async verifyDatabaseStatusIsNotVisible(): Promise { - await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible) - .notOk("Database status is still visible"); - } } /** diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index 668c1d5f40..ce4841cb02 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -177,4 +177,20 @@ export class MyRedisDatabasePage { await t.expect(actualList[k].trim()).eql(sortedList[k].trim()); } } + + /** + * Verify database status is visible + */ + async verifyDatabaseStatusIsVisible(): Promise { + await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible) + .ok("Database status is not visible"); + } + + /** + * Verify database status is not visible + */ + async verifyDatabaseStatusIsNotVisible(): Promise { + await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible) + .notOk("Database status is still visible"); + } } diff --git a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts index b5dc7e1df2..8a11c236a0 100644 --- a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts @@ -38,8 +38,10 @@ test await clickOnEditDatabaseByName(ossStandaloneConfig.databaseName); // Verify that user can cancel the Clone by clicking the “Cancel” or the “x” button await t.click(addRedisDatabasePage.cloneDatabaseButton); - // New connections indicator - await browserPage.verifyDatabaseStatusIsVisible(); + + // Verify new connection badge for cloned database + await myRedisDatabasePage.verifyDatabaseStatusIsVisible(); + await t.click(addRedisDatabasePage.cancelButton); await t.expect(myRedisDatabasePage.editAliasButton.withText('Clone ').exists).notOk('Clone panel is still displayed', { timeout: 2000 }); await clickOnEditDatabaseByName(ossStandaloneConfig.databaseName); @@ -69,8 +71,10 @@ test .meta({ rte: rte.ossCluster })('Verify that user can clone OSS Cluster', async t => { await clickOnEditDatabaseByName(ossClusterConfig.ossClusterDatabaseName); await t.click(addRedisDatabasePage.cloneDatabaseButton); + // New connections indicator - await browserPage.verifyDatabaseStatusIsVisible(); + await myRedisDatabasePage.verifyDatabaseStatusIsVisible(); + await t .expect(myRedisDatabasePage.editAliasButton.withText('Clone ').exists).ok('Clone panel is not displayed') .expect(addRedisDatabasePage.portInput.getAttribute('value')).eql(ossClusterConfig.ossClusterPort, 'Wrong port value') @@ -99,8 +103,10 @@ test .meta({ rte: rte.sentinel })('Verify that user can clone Sentinel', async t => { await clickOnEditDatabaseByName(ossSentinelConfig.name[1]); await t.click(addRedisDatabasePage.cloneDatabaseButton); - // New connections indicator - await browserPage.verifyDatabaseStatusIsVisible(); + + // Verify new connection badge for Sentinel db + await myRedisDatabasePage.verifyDatabaseStatusIsVisible(); + // Verify that for Sentinel Host and Port fields are replaced with editable Primary Group Name field await t .expect(myRedisDatabasePage.editAliasButton.withText('Clone ').exists).ok('Clone panel is not displayed') diff --git a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts index eedc1724e2..bee944e72c 100644 --- a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts @@ -32,34 +32,36 @@ test await deleteDatabase(ossStandaloneConfig.databaseName); })('Verify that user can add Standalone Database', async() => { await addNewStandaloneDatabase(ossStandaloneConfig); - await browserPage.verifyDatabaseStatusIsVisible(); + // Verify that user can see an indicator of databases that are added manually and not opened yet + await myRedisDatabasePage.verifyDatabaseStatusIsVisible(); await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); await t.click(browserPage.myRedisDbIcon); - // New connections indicator - await browserPage.verifyDatabaseStatusIsNotVisible(); + // Verify that user can't see an indicator of databases that were opened + await myRedisDatabasePage.verifyDatabaseStatusIsNotVisible(); }); test .meta({ rte: rte.reCluster }) .after(async() => { await deleteDatabase(redisEnterpriseClusterConfig.databaseName); })('Verify that user can add database from RE Cluster via auto-discover flow', async() => { - // New connections indicator await addNewREClusterDatabase(redisEnterpriseClusterConfig); - await browserPage.verifyDatabaseStatusIsVisible(); + // Verify that user can see an indicator of databases that are added using autodiscovery and not opened yet + // Verify new connection badge for RE cluster + await myRedisDatabasePage.verifyDatabaseStatusIsVisible(); }); test .meta({ env: env.web, rte: rte.ossCluster }) .after(async() => { await deleteDatabase(ossClusterConfig.ossClusterDatabaseName); })('Verify that user can add OSS Cluster DB', async() => { - // New connections indicator await addOSSClusterDatabase(ossClusterConfig); - await browserPage.verifyDatabaseStatusIsVisible(); + // Verify new connection badge for OSS cluster + await myRedisDatabasePage.verifyDatabaseStatusIsVisible(); }); test .meta({ rte: rte.reCloud })('Verify that user can add database from RE Cloud via auto-discover flow', async() => { - // New connections indicator await acceptLicenseTermsAndAddRECloudDatabase(cloudDatabaseConfig); - await browserPage.verifyDatabaseStatusIsVisible(); + // Verify new connection badge for RE cloud + await myRedisDatabasePage.verifyDatabaseStatusIsVisible(); }); diff --git a/tests/e2e/tests/smoke/database/connecting-to-the-db.e2e.ts b/tests/e2e/tests/smoke/database/connecting-to-the-db.e2e.ts index 96f7820ab0..785134bb3b 100644 --- a/tests/e2e/tests/smoke/database/connecting-to-the-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/connecting-to-the-db.e2e.ts @@ -27,6 +27,10 @@ test })('Verify that user can connect to Sentinel DB', async t => { // Add OSS Sentinel DB await discoverSentinelDatabase(ossSentinelConfig); + + // Verify new connection badge for Sentinel db + await myRedisDatabasePage.verifyDatabaseStatusIsVisible(); + // Get groups & their count const sentinelGroups = myRedisDatabasePage.dbNameList; const sentinelGroupsCount = await sentinelGroups.count; From 94de3e92a42f549414c9304e5513aa7a955e17a9 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 13 Dec 2022 16:56:35 +0100 Subject: [PATCH 35/57] deleted unused import --- .../e2e/tests/critical-path/database/clone-databases.e2e.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts index 8a11c236a0..dd292f834c 100644 --- a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts @@ -1,5 +1,5 @@ import { rte } from '../../../helpers/constants'; -import { AddRedisDatabasePage, BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; +import { AddRedisDatabasePage, MyRedisDatabasePage } from '../../../pageObjects'; import { commonUrl, ossClusterConfig, ossSentinelConfig, ossStandaloneConfig } from '../../../helpers/conf'; import { acceptLicenseTerms, clickOnEditDatabaseByName } from '../../../helpers/database'; import { @@ -16,9 +16,8 @@ const addRedisDatabasePage = new AddRedisDatabasePage(); const myRedisDatabasePage = new MyRedisDatabasePage(); const common = new Common(); const newOssDatabaseAlias = 'cloned oss cluster'; -const browserPage = new BrowserPage(); -fixture`Clone databases` +fixture `Clone databases` .meta({ type: 'critical_path' }) .page(commonUrl); test From 4258ede2505b40e35ed011acb5a2ab4aea0cf791 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 14 Dec 2022 09:55:52 +0200 Subject: [PATCH 36/57] resolve merge conflicts --- redisinsight/api/src/__mocks__/databases.ts | 4 +++- .../api/src/modules/database/database-connection.service.ts | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/redisinsight/api/src/__mocks__/databases.ts b/redisinsight/api/src/__mocks__/databases.ts index cd9db9d127..b86f8eb7b7 100644 --- a/redisinsight/api/src/__mocks__/databases.ts +++ b/redisinsight/api/src/__mocks__/databases.ts @@ -8,6 +8,7 @@ import { mockSentinelMasterDto } from 'src/__mocks__/redis-sentinel'; import { pick } from 'lodash'; import { RedisDatabaseInfoResponse } from 'src/modules/database/dto/redis-info.dto'; import { DatabaseOverview } from 'src/modules/database/models/database-overview'; +import { ClientContext, ClientMetadata } from 'src/common/models'; export const mockDatabaseId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-db-id'; @@ -120,8 +121,9 @@ export const mockNewDatabase = Object.assign(new Database(), { }); export const mockClientMetadata: ClientMetadata = { + session: undefined, databaseId: mockDatabase.id, - namespace: AppTool.Common, + context: ClientContext.Common, }; export const mockDatabaseOverview: DatabaseOverview = { diff --git a/redisinsight/api/src/modules/database/database-connection.service.ts b/redisinsight/api/src/modules/database/database-connection.service.ts index 895d129c32..8acb049c0d 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.ts @@ -32,7 +32,6 @@ export class DatabaseConnectionService { // mark database as not a new // will be refreshed after user navigate to particular database from the databases list // Note: move to a different place in case if we need to update such info more often - await this.repository.update(clientMetadata.databaseId, { const toUpdate: Partial = { new: false, lastConnection: new Date(), @@ -52,7 +51,7 @@ export class DatabaseConnectionService { })); } - await this.repository.update(databaseId, toUpdate); + await this.repository.update(clientMetadata.databaseId, toUpdate); this.logger.log(`Succeed to connect to database ${clientMetadata.databaseId}`); } From c09f6d96c2d8a5e66599d9ef8849d00b5ef8a40d Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 14 Dec 2022 10:09:12 +0200 Subject: [PATCH 37/57] resolve merge conflicts --- .../modules/database-import/database-import.service.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index 1ecb0b5a1b..5ceea13dd8 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -117,11 +117,9 @@ export class DatabaseImportService { try { const data: any = {}; - // set this is a new connection - data.new = true + // set this is a new connection + data.new = true; - this.fieldsMapSchema.forEach(([field, paths]) => { - let value; this.fieldsMapSchema.forEach(([field, paths]) => { let value; @@ -130,8 +128,8 @@ export class DatabaseImportService { return value === undefined; }); - set(data, field, value); - }); + set(data, field, value); + }); // set database name if needed if (!data.name) { From 65fbe73813b439c1ca0b00e9ecb14b2e1a0b27c2 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 14 Dec 2022 10:41:05 +0200 Subject: [PATCH 38/57] resolve merge conflicts --- .../api/src/__mocks__/database-import.ts | 4 + .../database-import.service.spec.ts | 70 ++++++++++++++++- .../database-import.service.ts | 77 ++++++++++++++++--- .../dto/import.database.dto.ts | 3 +- .../database/database-connection.service.ts | 21 ++++- .../database/entities/database.entity.ts | 1 + .../src/modules/database/models/database.ts | 2 + .../api/src/modules/redis/redis.service.ts | 24 +++++- 8 files changed, 188 insertions(+), 14 deletions(-) diff --git a/redisinsight/api/src/__mocks__/database-import.ts b/redisinsight/api/src/__mocks__/database-import.ts index f94da3ceb5..e15d864f41 100644 --- a/redisinsight/api/src/__mocks__/database-import.ts +++ b/redisinsight/api/src/__mocks__/database-import.ts @@ -62,3 +62,7 @@ export const mockDatabaseImportAnalytics = jest.fn(() => ({ sendImportResults: jest.fn(), sendImportFailed: jest.fn(), })); + +export const mockCertificateImportService = jest.fn(() => { + +}); diff --git a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts index 56773f0890..472e01ae02 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts @@ -1,6 +1,7 @@ import { pick } from 'lodash'; import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; import { + mockCertificateImportService, mockDatabase, mockDatabaseImportAnalytics, mockDatabaseImportFile, @@ -14,9 +15,11 @@ import { ConnectionType } from 'src/modules/database/entities/database.entity'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { ValidationError } from 'class-validator'; import { - NoDatabaseImportFileProvidedException, SizeLimitExceededDatabaseImportFileException, + NoDatabaseImportFileProvidedException, + SizeLimitExceededDatabaseImportFileException, UnableToParseDatabaseImportFileException, } from 'src/modules/database-import/exceptions'; +import { CertificateImportService } from 'src/modules/database-import/certificate-import.service'; describe('DatabaseImportService', () => { let service: DatabaseImportService; @@ -36,6 +39,10 @@ describe('DatabaseImportService', () => { create: jest.fn().mockResolvedValue(mockDatabase), })), }, + { + provide: CertificateImportService, + useFactory: mockCertificateImportService, + }, { provide: DatabaseImportAnalytics, useFactory: mockDatabaseImportAnalytics, @@ -154,6 +161,7 @@ describe('DatabaseImportService', () => { it('should create cluster database', async () => { await service['createDatabase']({ ...mockDatabase, + connectionType: undefined, cluster: true, }, 0); @@ -163,4 +171,64 @@ describe('DatabaseImportService', () => { }); }); }); + + describe('determineConnectionType', () => { + const tcs = [ + // common + { input: {}, output: ConnectionType.NOT_CONNECTED }, + // isCluster + { input: { isCluster: true }, output: ConnectionType.CLUSTER }, + { input: { isCluster: false }, output: ConnectionType.NOT_CONNECTED }, + { input: { isCluster: undefined }, output: ConnectionType.NOT_CONNECTED }, + // sentinelMasterName + { input: { sentinelMasterName: 'some name' }, output: ConnectionType.SENTINEL }, + // connectionType + { input: { connectionType: ConnectionType.STANDALONE }, output: ConnectionType.STANDALONE }, + { input: { connectionType: ConnectionType.CLUSTER }, output: ConnectionType.CLUSTER }, + { input: { connectionType: ConnectionType.SENTINEL }, output: ConnectionType.SENTINEL }, + { input: { connectionType: 'something not supported' }, output: ConnectionType.NOT_CONNECTED }, + // type + { input: { type: 'standalone' }, output: ConnectionType.STANDALONE }, + { input: { type: 'cluster' }, output: ConnectionType.CLUSTER }, + { input: { type: 'sentinel' }, output: ConnectionType.SENTINEL }, + { input: { type: 'something not supported' }, output: ConnectionType.NOT_CONNECTED }, + // priority tests + { + input: { + connectionType: ConnectionType.SENTINEL, + type: 'standalone', + isCluster: true, + sentinelMasterName: 'some name', + }, + output: ConnectionType.SENTINEL, + }, + { + input: { + type: 'standalone', + isCluster: true, + sentinelMasterName: 'some name', + }, + output: ConnectionType.STANDALONE, + }, + { + input: { + isCluster: true, + sentinelMasterName: 'some name', + }, + output: ConnectionType.CLUSTER, + }, + { + input: { + sentinelMasterName: 'some name', + }, + output: ConnectionType.SENTINEL, + }, + ]; + + tcs.forEach((tc) => { + it(`should return ${tc.output} when called with ${JSON.stringify(tc.input)}`, () => { + expect(DatabaseImportService.determineConnectionType(tc.input)).toEqual(tc.output); + }); + }); + }); }); diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index bd5965d3a5..2c010a80f0 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -37,13 +37,20 @@ export class DatabaseImportService { ['port', ['port']], ['db', ['db']], ['isCluster', ['cluster']], + ['type', ['type']], + ['connectionType', ['connectionType']], ['tls', ['tls', 'ssl']], - ['tlsServername', ['tlsServername', 'sni_name', 'sni_server_name']], + ['tlsServername', ['tlsServername']], ['tlsCaName', ['caCert.name']], - ['tlsCaCert', ['caCert.certificate', 'sslOptions.ca', 'ssl_ca_cert_path']], + ['tlsCaCert', ['caCert.certificate', 'caCert', 'sslOptions.ca', 'ssl_ca_cert_path']], ['tlsClientName', ['clientCert.name']], - ['tlsClientCert', ['clientCert.certificate', 'sslOptions.cert', 'ssl_local_cert_path']], - ['tlsClientKey', ['clientCert.key', 'sslOptions.key', 'ssl_private_key_path']], + ['tlsClientCert', ['clientCert.certificate', 'certificate', 'sslOptions.cert', 'ssl_local_cert_path']], + ['tlsClientKey', ['clientCert.key', 'keyFile', 'sslOptions.key', 'ssl_private_key_path']], + ['sentinelMasterName', ['sentinelMaster.name', 'sentinelOptions.masterName', 'sentinelOptions.name']], + ['sentinelMasterUsername', ['sentinelMaster.username']], + ['sentinelMasterPassword', [ + 'sentinelMaster.password', 'sentinelOptions.nodePassword', 'sentinelOptions.sentinelPassword', + ]], ]; constructor( @@ -83,6 +90,8 @@ export class DatabaseImportService { fail: [], }; + console.log('___ items', items); + // it is very important to insert databases on-by-one to avoid db constraint errors await items.reduce((prev, item, index) => prev.finally(() => this.createDatabase(item, index) .then((result) => { @@ -144,11 +153,18 @@ export class DatabaseImportService { data.name = `${data.host}:${data.port}`; } - // determine database type - if (data.isCluster) { - data.connectionType = ConnectionType.CLUSTER; - } else { - data.connectionType = ConnectionType.STANDALONE; + data.connectionType = DatabaseImportService.determineConnectionType(data); + + if (data?.sentinelMasterName) { + data.sentinelMaster = { + name: data.sentinelMasterName, + username: data.sentinelMasterUsername, + password: data.sentinelMasterPassword, + }; + data.nodes = [{ + host: data.host, + port: parseInt(data.port, 10), + }]; } if (data?.tlsCaCert) { @@ -237,6 +253,49 @@ export class DatabaseImportService { } } + /** + * Try to determine connection type based on input data + * Should return NOT_CONNECTED when it is not possible + * @param data + */ + static determineConnectionType(data: any = {}): ConnectionType { + if (data?.connectionType) { + switch (data.connectionType) { + case ConnectionType.CLUSTER: + return ConnectionType.CLUSTER; + case ConnectionType.SENTINEL: + return ConnectionType.SENTINEL; + case ConnectionType.STANDALONE: + return ConnectionType.STANDALONE; + default: + return ConnectionType.NOT_CONNECTED; + } + } + + if (data?.type) { + switch (data.type) { + case 'cluster': + return ConnectionType.CLUSTER; + case 'sentinel': + return ConnectionType.SENTINEL; + case 'standalone': + return ConnectionType.STANDALONE; + default: + return ConnectionType.NOT_CONNECTED; + } + } + + if (data?.isCluster === true) { + return ConnectionType.CLUSTER; + } + + if (data?.sentinelMasterName) { + return ConnectionType.SENTINEL; + } + + return ConnectionType.NOT_CONNECTED; + } + /** * Try to parse file based on mimetype and known\supported formats * @param file diff --git a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts index 46215ef872..e62ba1a3be 100644 --- a/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -13,7 +13,7 @@ import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client- export class ImportDatabaseDto extends PickType(Database, [ 'host', 'port', 'name', 'db', 'username', 'password', - 'connectionType', 'tls', 'verifyServerCert', + 'connectionType', 'tls', 'verifyServerCert', 'sentinelMaster', 'nodes', ] as const) { @Expose() @IsNotEmpty() @@ -43,5 +43,4 @@ export class ImportDatabaseDto extends PickType(Database, [ @Type(clientCertTransformer) @ValidateNested() clientCert?: CreateClientCertificateDto | UseClientCertificateDto; - } diff --git a/redisinsight/api/src/modules/database/database-connection.service.ts b/redisinsight/api/src/modules/database/database-connection.service.ts index 932458daaa..be490865a1 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.ts @@ -9,6 +9,7 @@ import { ClientMetadata } from 'src/modules/redis/models/client-metadata'; import { DatabaseService } from 'src/modules/database/database.service'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; import { Database } from 'src/modules/database/models/database'; +import { ConnectionType } from 'src/modules/database/entities/database.entity'; @Injectable() export class DatabaseConnectionService { @@ -111,11 +112,29 @@ export class DatabaseConnectionService { const connectionName = generateRedisConnectionName(clientMetadata.namespace, clientMetadata.databaseId); try { - return await this.redisService.connectToDatabaseInstance( + const client = await this.redisService.connectToDatabaseInstance( database, clientMetadata.namespace, connectionName, ); + + if (database.connectionType === ConnectionType.NOT_CONNECTED) { + let connectionType = ConnectionType.STANDALONE; + + // cluster check + if (client.isCluster) { + connectionType = ConnectionType.CLUSTER; + } + + // sentinel check + if (client?.options?.['sentinels']?.length) { + connectionType = ConnectionType.SENTINEL; + } + + await this.repository.update(database.id, { connectionType }); + } + + return client; } catch (error) { this.logger.error('Failed to create database client', error); const exception = getRedisConnectionException( diff --git a/redisinsight/api/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index 179df10fe0..6d9b8decbd 100644 --- a/redisinsight/api/src/modules/database/entities/database.entity.ts +++ b/redisinsight/api/src/modules/database/entities/database.entity.ts @@ -21,6 +21,7 @@ export enum ConnectionType { STANDALONE = 'STANDALONE', CLUSTER = 'CLUSTER', SENTINEL = 'SENTINEL', + NOT_CONNECTED = 'NOT CONNECTED', } @Entity('database_instance') diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index 8531689622..2abac76c3d 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -142,6 +142,8 @@ export class Database { type: Endpoint, isArray: true, }) + @IsOptional() + @Type(() => Endpoint) @Expose() nodes?: Endpoint[]; diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index 1780ad3b6e..4e845114de 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -195,12 +195,34 @@ export class RedisService { client = await this.createSentinelClient(database, nodes, tool, true, connectionName); break; default: - client = await this.createStandaloneClient(database, tool, true, connectionName); + // AUTO + client = await this.createClientAutomatically(database, tool, connectionName); } return client; } + public async createClientAutomatically(database: Database, tool: AppTool, connectionName) { + // try sentinel connection + if (database?.sentinelMaster) { + try { + return await this.createSentinelClient(database, database.nodes, tool, true, connectionName); + } catch (e) { + // ignore error + } + } + + // try cluster connection + try { + return await this.createClusterClient(database, database.nodes, true, connectionName); + } catch (e) { + // ignore error + } + + // Standalone in any other case + return this.createStandaloneClient(database, tool, true, connectionName); + } + public isClientConnected(client: Redis | Cluster): boolean { try { return client.status === 'ready'; From 6f8868fce24b52ca7909b5064d74bd95d057b527 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 14 Dec 2022 10:42:14 +0200 Subject: [PATCH 39/57] temporary run all tests --- .circleci/config.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index dcb3c7bac8..f7aac81540 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -896,10 +896,11 @@ workflows: matrix: alias: itest-code parameters: - rte: *iTestsNamesShort +# rte: *iTestsNamesShort + rte: *iTestsNames name: ITest - << matrix.rte >> (code) - requires: - - UTest - API +# requires: +# - UTest - API # E2E tests for "e2e/feature" or "e2e/bugfix" branches only e2e-tests: jobs: From 2e9856f95750184a0aef1e225a793ecea34b78e8 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 14 Dec 2022 13:18:15 +0300 Subject: [PATCH 40/57] #RI-3903 - add tests, ui changes --- .../ImportDatabasesDialog.tsx | 10 +- .../components/ResultsLog/ResultsLog.spec.tsx | 122 ++++++++++++++++++ .../components/ResultsLog/styles.module.scss | 8 +- .../TableResult/TableResult.spec.tsx | 46 +++++++ .../ui/src/slices/instances/instances.ts | 1 - .../slices/tests/instances/instances.spec.ts | 25 ++-- .../themes/dark_theme/_dark_theme.lazy.scss | 4 + .../themes/dark_theme/_theme_color.scss | 4 + .../themes/light_theme/_light_theme.lazy.scss | 4 + .../themes/light_theme/_theme_color.scss | 4 + 10 files changed, 211 insertions(+), 17 deletions(-) create mode 100644 redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.spec.tsx create mode 100644 redisinsight/ui/src/components/import-databases-dialog/components/TableResult/TableResult.spec.tsx diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx index 9737f4e9dc..9187411a36 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx @@ -2,7 +2,8 @@ import { EuiButton, EuiFilePicker, EuiFlexGroup, - EuiFlexItem, EuiIcon, + EuiFlexItem, + EuiIcon, EuiLoadingSpinner, EuiModal, EuiModalBody, @@ -49,11 +50,11 @@ const ImportDatabasesDialog = ({ onClose }: Props) => { } const handleOnClose = () => { - dispatch(resetImportInstances()) if (data?.success?.length || data?.partial?.length) { dispatch(fetchInstancesAction()) } onClose(!data) + dispatch(resetImportInstances()) } const onSubmit = () => { @@ -75,7 +76,7 @@ const ImportDatabasesDialog = ({ onClose }: Props) => { - {(!data && !error) ? 'Import Database Connections' : 'Import results'} + {(!data && !error) ? 'Import Database Connections' : 'Import Results'} @@ -113,9 +114,10 @@ const ImportDatabasesDialog = ({ onClose }: Props) => { {error && (
- + Failed to add database connections + {error}
)} diff --git a/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.spec.tsx b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.spec.tsx new file mode 100644 index 0000000000..46f3717d5b --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.spec.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import { render, screen, fireEvent, within } from 'uiSrc/utils/test-utils' +import { ImportDatabasesData } from 'uiSrc/slices/interfaces' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import ResultsLog from './ResultsLog' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const mockedError = { statusCode: 400, message: 'message', error: 'error' } +describe('ResultsLog', () => { + it('should render', () => { + const mockedData = { total: 0, fail: [], partial: [], success: [] } + render() + }) + + it('should be all collapsed nav groups', () => { + const mockedData: ImportDatabasesData = { + total: 3, + fail: [{ index: 0, status: 'fail', errors: [mockedError] }], + partial: [{ index: 2, status: 'fail', errors: [mockedError] }], + success: [{ index: 1, status: 'success', port: 1233, host: 'localhost' }] + } + render() + + expect(screen.getByTestId('success-results-closed')).toBeInTheDocument() + expect(screen.getByTestId('partial-results-closed')).toBeInTheDocument() + expect(screen.getByTestId('failed-results-closed')).toBeInTheDocument() + }) + + it('should open and collapse other groups', () => { + const mockedData: ImportDatabasesData = { + total: 3, + fail: [{ index: 0, status: 'fail', errors: [mockedError] }], + partial: [{ index: 2, status: 'fail', errors: [mockedError] }], + success: [{ index: 1, status: 'success', port: 1233, host: 'localhost' }] + } + render() + + fireEvent.click( + within(screen.getByTestId('success-results-closed')).getByRole('button') + ) + expect(screen.getByTestId('success-results-open')).toBeInTheDocument() + + expect(screen.getByTestId('partial-results-closed')).toBeInTheDocument() + expect(screen.getByTestId('failed-results-closed')).toBeInTheDocument() + + fireEvent.click( + within(screen.getByTestId('failed-results-closed')).getByRole('button') + ) + expect(screen.getByTestId('failed-results-open')).toBeInTheDocument() + + expect(screen.getByTestId('partial-results-closed')).toBeInTheDocument() + expect(screen.getByTestId('success-results-closed')).toBeInTheDocument() + + fireEvent.click( + within(screen.getByTestId('partial-results-closed')).getByRole('button') + ) + expect(screen.getByTestId('partial-results-open')).toBeInTheDocument() + + expect(screen.getByTestId('failed-results-closed')).toBeInTheDocument() + expect(screen.getByTestId('success-results-closed')).toBeInTheDocument() + }) + + it('should show proper items length', () => { + const mockedData: ImportDatabasesData = { + total: 4, + fail: [{ index: 0, status: 'fail', errors: [mockedError] }], + partial: [{ index: 2, status: 'fail', errors: [mockedError] }], + success: [ + { index: 1, status: 'success', port: 1233, host: 'localhost' }, + { index: 3, status: 'success', port: 1233, host: 'localhost' } + ] + } + render() + + expect( + within(screen.getByTestId('success-results-closed')).getByTestId('number-of-dbs') + ).toHaveTextContent('2') + expect( + within(screen.getByTestId('partial-results-closed')).getByTestId('number-of-dbs') + ).toHaveTextContent('1') + expect( + within(screen.getByTestId('failed-results-closed')).getByTestId('number-of-dbs') + ).toHaveTextContent('1') + }) + + it('should call proper telemetry event after click', () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + + const mockedData: ImportDatabasesData = { + total: 3, + fail: [{ index: 0, status: 'fail', errors: [mockedError] }], + partial: [{ index: 2, status: 'fail', errors: [mockedError] }], + success: [{ index: 1, status: 'success', port: 1233, host: 'localhost' }] + } + render() + + fireEvent.click( + within(screen.getByTestId('success-results-closed')).getByRole('button') + ) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_LOG_VIEWED, + eventData: { + length: 1, + name: 'success' + } + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + + fireEvent.click( + within(screen.getByTestId('success-results-open')).getByRole('button') + ) + + expect(sendEventTelemetry).not.toBeCalled() + }) +}) diff --git a/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/styles.module.scss b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/styles.module.scss index 6b15697083..8ce336e68f 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/styles.module.scss +++ b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/styles.module.scss @@ -26,19 +26,19 @@ &:global(.success) { &:before { - background-color: #13A450; + background-color: var(--successBorderColor); } } &:global(.partial) { &:before { - background-color: #9D6901; + background-color: var(--warningBorderColor); } } &:global(.failed) { &:before { - background-color: #AD0017; + background-color: var(--errorBorderColor); } } @@ -65,7 +65,7 @@ background-color: var(--euiColorEmptyShade); padding: 12px 24px 12px 32px !important; border-radius: 4px; - border: 1px solid #3D3D3D; + border: 1px solid var(--separatorColor); } .euiCollapsibleNavGroup__children { padding: 0 !important; diff --git a/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/TableResult.spec.tsx b/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/TableResult.spec.tsx new file mode 100644 index 0000000000..84aaded8a5 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/components/TableResult/TableResult.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { render, screen } from 'uiSrc/utils/test-utils' +import TableResult from './TableResult' + +const mockedError = { statusCode: 400, message: 'message', error: 'error' } + +describe('TableResult', () => { + it('should render', () => { + render() + }) + + it('should not render table for empty data', () => { + render() + + expect(screen.queryByTestId('result-log-table')).not.toBeInTheDocument() + }) + + it('should render table data with success messages', () => { + render( + + ) + + expect(screen.getByTestId('table-index-0')).toHaveTextContent('(0)') + expect(screen.getByTestId('table-index-1')).toHaveTextContent('(1)') + expect(screen.getByTestId('table-host-port-0')).toHaveTextContent('localhost:1233') + expect(screen.getByTestId('table-host-port-1')).toHaveTextContent('localhost2:5233') + expect(screen.getByTestId('table-result-0')).toHaveTextContent('Successful') + expect(screen.getByTestId('table-result-1')).toHaveTextContent('Successful') + }) + + it('should render table data with error messages', () => { + render( + + ) + expect(screen.getByTestId('table-result-0')).toHaveTextContent([mockedError, mockedError].map((e) => e.message).join('')) + expect(screen.getByTestId('table-result-1')).toHaveTextContent([mockedError].map((e) => e.message).join('')) + }) +}) diff --git a/redisinsight/ui/src/slices/instances/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts index d523757f91..7fc52c2789 100644 --- a/redisinsight/ui/src/slices/instances/instances.ts +++ b/redisinsight/ui/src/slices/instances/instances.ts @@ -544,7 +544,6 @@ export function uploadInstancesFile( } catch (error) { const errorMessage = getApiErrorMessage(error) dispatch(importInstancesFromFileFailure(errorMessage)) - dispatch(addErrorNotification(error)) onFailAction?.() } } diff --git a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts index d148825388..24a5cff3dc 100644 --- a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts @@ -47,7 +47,8 @@ import reducer, { importInstancesFromFileSuccess, importInstancesFromFileFailure, resetImportInstances, - importInstancesSelector, uploadInstancesFile + importInstancesSelector, + uploadInstancesFile } from '../../instances/instances' import { addErrorNotification, addMessageNotification, IAddInstanceErrorPayload } from '../../app/notifications' import { ConnectionType, InitialStateInstances, Instance } from '../../interfaces' @@ -542,9 +543,12 @@ describe('instances slice', () => { describe('importInstancesFromFileSuccess', () => { it('should properly set state', () => { // Arrange + const mockedError = { statusCode: 400, message: 'message', error: 'error' } const data = { - success: 3, - total: 5 + total: 3, + fail: [{ index: 0, status: 'fail', errors: [mockedError] }], + partial: [{ index: 2, status: 'fail', errors: [mockedError] }], + success: [{ index: 1, status: 'success', port: 1233, host: 'localhost' }] } const state = { ...initialState.importInstances, @@ -591,13 +595,16 @@ describe('instances slice', () => { describe('resetImportInstances', () => { it('should properly set state', () => { // Arrange + const mockedError = { statusCode: 400, message: 'message', error: 'error' } const currentState = { ...initialState, importInstances: { ...initialState.importInstances, data: { - success: 1, - total: 2 + total: 3, + fail: [{ index: 0, status: 'fail', errors: [mockedError] }], + partial: [{ index: 2, status: 'fail', errors: [mockedError] }], + success: [{ index: 1, status: 'success', port: 1233, host: 'localhost' }] } } } @@ -1110,9 +1117,12 @@ describe('instances slice', () => { it('should call proper actions on success', async () => { // Arrange const formData = new FormData() + const mockedError = { statusCode: 400, message: 'message', error: 'error' } const data = { - success: 0, - total: 1 + total: 3, + fail: [{ index: 0, status: 'fail', errors: [mockedError] }], + partial: [{ index: 2, status: 'fail', errors: [mockedError] }], + success: [{ index: 1, status: 'success', port: 1233, host: 'localhost' }] } const responsePayload = { data, status: 200 } @@ -1150,7 +1160,6 @@ describe('instances slice', () => { const expectedActions = [ importInstancesFromFile(), importInstancesFromFileFailure(responsePayload.response.data.message), - addErrorNotification(responsePayload as AxiosError), ] expect(store.getActions()).toEqual(expectedActions) }) diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 2dee6e4865..4311b3f876 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -131,6 +131,10 @@ --monacoBgColor: #{$monacoBgColor}; + --successBorderColor: #{$successBorderColor}; + --warningBorderColor: #{$warningBorderColor}; + --errorBorderColor: #{$errorBorderColor}; + // KeyTypes --typeHashColor: #{$typeHashColor}; --typeListColor: #{$typeListColor}; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index bcfb32c4f2..f17f38159a 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -91,6 +91,10 @@ $overlayPromoNYColor: #0000001a; $monacoBgColor: #111; +$successBorderColor: #13A450; +$warningBorderColor: #9D6901; +$errorBorderColor: #AD0017; + // Types colors $typeHashColor: #364cff; $typeListColor: #008556; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index a9fbfa450e..0d0b4ae649 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -133,6 +133,10 @@ --monacoBgColor: #{$monacoBgColor}; + --successBorderColor: #{$successBorderColor}; + --warningBorderColor: #{$warningBorderColor}; + --errorBorderColor: #{$errorBorderColor}; + // KeyTypes --typeHashColor: #{$typeHashColor}; --typeListColor: #{$typeListColor}; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index e925a43fe6..3683d216f7 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -88,6 +88,10 @@ $overlayPromoNYColor: #ffffff1a; $monacoBgColor: #f0f2f7; +$successBorderColor: #5BC69B; +$warningBorderColor: #FFAF2B; +$errorBorderColor: #F74B57; + // Types colors $typeHashColor: #cdddf8; $typeListColor: #a5d4c3; From ddb6ebdcefba719809961b503a8cca9cd66e8227 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Wed, 14 Dec 2022 13:40:13 +0300 Subject: [PATCH 41/57] #RI-3903 - fix tests --- .../ImportDatabasesDialog.spec.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx index 61be09a4f4..ef6e712a3b 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx @@ -92,31 +92,25 @@ describe('ImportDatabasesDialog', () => { expect(screen.getByTestId('file-loading-indicator')).toBeInTheDocument() }) - it('should render success message when at least 1 database added', () => { + it('should not render error message without error', () => { (importInstancesSelector as jest.Mock).mockImplementation(() => ({ loading: false, - data: { - success: 1, - total: 2 - } + data: {} })) render() - expect(screen.getByTestId('result-success')).toBeInTheDocument() expect(screen.queryByTestId('result-failed')).not.toBeInTheDocument() }) it('should render error message when 0 success databases added', () => { (importInstancesSelector as jest.Mock).mockImplementation(() => ({ loading: false, - data: { - success: 0, - total: 2 - } + data: null, + error: 'Error message' })) render() expect(screen.getByTestId('result-failed')).toBeInTheDocument() - expect(screen.queryByTestId('result-success')).not.toBeInTheDocument() + expect(screen.getByTestId('result-failed')).toHaveTextContent('Error message') }) }) From b02beea00e7f02d24eb3b8f9014e4213af7b7758 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 14 Dec 2022 13:29:07 +0200 Subject: [PATCH 42/57] rollback CircleCI configs + fix PR comments + fix bug when unable to descover sentinel databases with existing certs --- .circleci/config.yml | 7 +++---- .../database-import/database-import.service.ts | 15 +++------------ .../redis-sentinel/redis-sentinel.service.spec.ts | 6 ++++++ .../redis-sentinel/redis-sentinel.service.ts | 5 ++++- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f7aac81540..dcb3c7bac8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -896,11 +896,10 @@ workflows: matrix: alias: itest-code parameters: -# rte: *iTestsNamesShort - rte: *iTestsNames + rte: *iTestsNamesShort name: ITest - << matrix.rte >> (code) -# requires: -# - UTest - API + requires: + - UTest - API # E2E tests for "e2e/feature" or "e2e/bugfix" branches only e2e-tests: jobs: diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index 2c010a80f0..7d146edfe4 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -90,8 +90,6 @@ export class DatabaseImportService { fail: [], }; - console.log('___ items', items); - // it is very important to insert databases on-by-one to avoid db constraint errors await items.reduce((prev, item, index) => prev.finally(() => this.createDatabase(item, index) .then((result) => { @@ -260,16 +258,9 @@ export class DatabaseImportService { */ static determineConnectionType(data: any = {}): ConnectionType { if (data?.connectionType) { - switch (data.connectionType) { - case ConnectionType.CLUSTER: - return ConnectionType.CLUSTER; - case ConnectionType.SENTINEL: - return ConnectionType.SENTINEL; - case ConnectionType.STANDALONE: - return ConnectionType.STANDALONE; - default: - return ConnectionType.NOT_CONNECTED; - } + return (data.connectionType in ConnectionType) + ? ConnectionType[data.connectionType] + : ConnectionType.NOT_CONNECTED; } if (data?.type) { diff --git a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts index 94591a26ab..384a8ebcd4 100644 --- a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.spec.ts @@ -3,6 +3,7 @@ import { BadRequestException } from '@nestjs/common'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { RedisService } from 'src/modules/redis/redis.service'; import { + mockDatabaseFactory, mockDatabaseInfoProvider, mockDatabaseService, mockIORedisClient, @@ -15,6 +16,7 @@ import { RedisSentinelService } from 'src/modules/redis-sentinel/redis-sentinel. import { RedisSentinelAnalytics } from 'src/modules/redis-sentinel/redis-sentinel.analytics'; import { DatabaseService } from 'src/modules/database/database.service'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; +import { DatabaseFactory } from 'src/modules/database/providers/database.factory'; describe('RedisSentinelService', () => { let service: RedisSentinelService; @@ -38,6 +40,10 @@ describe('RedisSentinelService', () => { provide: DatabaseService, useFactory: mockDatabaseService, }, + { + provide: DatabaseFactory, + useFactory: mockDatabaseFactory, + }, { provide: DatabaseInfoProvider, useFactory: mockDatabaseInfoProvider, diff --git a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts index 540d5a6292..2ae406f4f5 100644 --- a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts @@ -12,6 +12,7 @@ import { getRedisConnectionException } from 'src/utils'; import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; import { RedisSentinelAnalytics } from 'src/modules/redis-sentinel/redis-sentinel.analytics'; import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider'; +import { DatabaseFactory } from 'src/modules/database/providers/database.factory'; @Injectable() export class RedisSentinelService { @@ -20,6 +21,7 @@ export class RedisSentinelService { constructor( private readonly redisService: RedisService, private readonly databaseService: DatabaseService, + private readonly databaseFactory: DatabaseFactory, private readonly databaseInfoProvider: DatabaseInfoProvider, private readonly redisSentinelAnalytics: RedisSentinelAnalytics, ) {} @@ -113,7 +115,8 @@ export class RedisSentinelService { this.logger.log('Connection and getting sentinel masters.'); let result: SentinelMaster[]; try { - const client = await this.redisService.createStandaloneClient(dto, AppTool.Common, false); + const database = await this.databaseFactory.createStandaloneDatabaseModel(dto); + const client = await this.redisService.createStandaloneClient(database, AppTool.Common, false); result = await this.databaseInfoProvider.determineSentinelMasterGroups(client); this.redisSentinelAnalytics.sendGetSentinelMastersSucceedEvent(result); From 9e3444d59967255fbbc5759b0c7f32e320a33001 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 14 Dec 2022 13:45:46 +0200 Subject: [PATCH 43/57] resolve conflicts. again... --- redisinsight/api/src/modules/redis/redis.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index 24c5b8c8d5..de94a88e79 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -190,7 +190,7 @@ export class RedisService { return client; } - public async createClientAutomatically(database: Database, tool: AppTool, connectionName) { + public async createClientAutomatically(database: Database, tool: ClientContext, connectionName) { // try sentinel connection if (database?.sentinelMaster) { try { From feebb5213f220828443ed7cde9b07719935c39ee Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Thu, 15 Dec 2022 16:44:36 +0300 Subject: [PATCH 44/57] #RI-3938 - fix modal width, add ":" --- .../import-databases-dialog/ImportDatabasesDialog.tsx | 3 ++- .../components/ResultsLog/ResultsLog.tsx | 2 +- .../import-databases-dialog/styles.module.scss | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx index 9187411a36..439dd337d2 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx @@ -24,6 +24,7 @@ import { } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { Nullable } from 'uiSrc/utils' +import cx from 'classnames' import ResultsLog from './components/ResultsLog' import styles from './styles.module.scss' @@ -72,7 +73,7 @@ const ImportDatabasesDialog = ({ onClose }: Props) => { const isShowForm = !loading && !data && !error return ( - + diff --git a/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.tsx b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.tsx index 3a0359445b..3446ea3dd3 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.tsx +++ b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.tsx @@ -38,7 +38,7 @@ const ResultsLog = ({ data }: Props) => { const CollapsibleNavTitle = ({ title, length = 0 }: { title: string, length: number }) => (
- {title} + {title}: {length}
) diff --git a/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss index 8fda92df89..cb918d12c3 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss +++ b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss @@ -4,6 +4,15 @@ max-width: 700px !important; min-height: 270px !important; + &.result { + width: 500px !important; + + @media screen and (min-width: 1024px) { + width: 700px !important; + min-width: 700px !important; + } + } + :global { .euiModalHeader { padding: 4px 42px 20px 30px; From 753342c5f77932b0856c128b3ac0141dea57d1c7 Mon Sep 17 00:00:00 2001 From: "Roman.Sergeenko" Date: Thu, 15 Dec 2022 17:06:56 +0300 Subject: [PATCH 45/57] #RI-3938 - fix pr comments --- .../import-databases-dialog/ImportDatabasesDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx index 439dd337d2..5065e3ee6e 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx @@ -16,6 +16,7 @@ import { } from '@elastic/eui' import React, { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' import { fetchInstancesAction, importInstancesSelector, @@ -24,7 +25,7 @@ import { } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { Nullable } from 'uiSrc/utils' -import cx from 'classnames' + import ResultsLog from './components/ResultsLog' import styles from './styles.module.scss' From 7f36cda53538d1b2bd300413afcf11a68ee025f2 Mon Sep 17 00:00:00 2001 From: Zalenski Egor <63463140+zalenskiSofteq@users.noreply.github.com> Date: Thu, 15 Dec 2022 22:53:35 +0800 Subject: [PATCH 46/57] #RI-3940 - When we edit db name, we are getting "new indicator" --- redisinsight/api/src/modules/database/database.service.ts | 7 ++++--- .../api/src/modules/database/providers/database.factory.ts | 1 - .../api/test/api/database/PUT-databases-id.test.ts | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/redisinsight/api/src/modules/database/database.service.ts b/redisinsight/api/src/modules/database/database.service.ts index 8c29e28050..8c9800fc85 100644 --- a/redisinsight/api/src/modules/database/database.service.ts +++ b/redisinsight/api/src/modules/database/database.service.ts @@ -88,9 +88,10 @@ export class DatabaseService { try { this.logger.log('Creating new database.'); - const database = await this.repository.create( - await this.databaseFactory.createDatabaseModel(classToClass(Database, dto)), - ); + const database = await this.repository.create({ + ...await this.databaseFactory.createDatabaseModel(classToClass(Database, dto)), + new: true, + }); // todo: clarify if we need this and if yes - rethink implementation try { diff --git a/redisinsight/api/src/modules/database/providers/database.factory.ts b/redisinsight/api/src/modules/database/providers/database.factory.ts index a8273bef69..24723f3d8a 100644 --- a/redisinsight/api/src/modules/database/providers/database.factory.ts +++ b/redisinsight/api/src/modules/database/providers/database.factory.ts @@ -46,7 +46,6 @@ export class DatabaseFactory { model.modules = await this.databaseInfoProvider.determineDatabaseModules(client); model.lastConnection = new Date(); - model.new = true await client.disconnect(); diff --git a/redisinsight/api/test/api/database/PUT-databases-id.test.ts b/redisinsight/api/test/api/database/PUT-databases-id.test.ts index f25b3e7ab8..95a09152cf 100644 --- a/redisinsight/api/test/api/database/PUT-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PUT-databases-id.test.ts @@ -183,8 +183,7 @@ describe(`PUT /databases/:id`, () => { after: async () => { newDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID_3); expect(newDatabase).to.contain({ - ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection']), - new: true, + ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new']), host: constants.TEST_REDIS_HOST, port: constants.TEST_REDIS_PORT, }); From 017af2cbdd9f32eff7272f9ea80b238ba3733ab4 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Fri, 16 Dec 2022 14:04:57 +0100 Subject: [PATCH 47/57] add test file with certificates to import --- tests/e2e/common-actions/databases-actions.ts | 3 +- .../pageObjects/my-redis-databases-page.ts | 6 + tests/e2e/test-data/racompass-invalid.json | 1 - tests/e2e/test-data/rdm-full.json | 218 ++++++++++++++++++ .../database/import-databases.e2e.ts | 59 ++++- 5 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/test-data/rdm-full.json diff --git a/tests/e2e/common-actions/databases-actions.ts b/tests/e2e/common-actions/databases-actions.ts index 3167ce1b2c..08bea3bda9 100644 --- a/tests/e2e/common-actions/databases-actions.ts +++ b/tests/e2e/common-actions/databases-actions.ts @@ -24,9 +24,8 @@ export class DatabasesActions { .click(myRedisDatabasePage.importDatabasesBtn) .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [fileParameters.path]) .click(myRedisDatabasePage.submitImportBtn) - .expect(myRedisDatabasePage.successImportMessage.exists).ok(`Successfully added ${fileParameters.type} databases message not displayed`); + .expect(myRedisDatabasePage.importDialogTitle.textContent).eql('Import Results', `Databases from ${fileParameters.type} not imported`); } - } /** diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index ce4841cb02..c45ab329e1 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -7,6 +7,8 @@ export class MyRedisDatabasePage { //*Target any element/component via data-id, if possible! //*The following categories are ordered alphabetically (Alerts, Buttons, Checkboxes, etc.). //------------------------------------------------------------------------------------------- + // CSS Selectors + cssNumberOfDbs = '[data-testid=number-of-dbs]'; //BUTTONS settingsButton = Selector('[data-testid=settings-page-btn]'); workbenchButton = Selector('[data-testid=workbench-page-btn]'); @@ -63,8 +65,12 @@ export class MyRedisDatabasePage { noResultsFoundText = Selector('div').withExactText('No databases matched your search. Try reducing the criteria.'); failedImportMessage = Selector('[data-testid=result-failed]'); successImportMessage = Selector('[data-testid=result-success]'); + importDialogTitle = Selector('[data-testid=import-dbs-dialog-title]'); // DIALOG importDbDialog = Selector('[data-testid=import-dbs-dialog]'); + successResultsAccordion = Selector('[data-testid^=success-results-]'); + partialResultsAccordion = Selector('[data-testid^=partial-results-]'); + failedResultsAccordion = Selector('[data-testid^=failed-results-]'); /** * Click on the database by name diff --git a/tests/e2e/test-data/racompass-invalid.json b/tests/e2e/test-data/racompass-invalid.json index aab8b3e325..fac9e31a5a 100644 --- a/tests/e2e/test-data/racompass-invalid.json +++ b/tests/e2e/test-data/racompass-invalid.json @@ -167,5 +167,4 @@ "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9", "cluster": true, "name": "vd long host" - } ] \ No newline at end of file diff --git a/tests/e2e/test-data/rdm-full.json b/tests/e2e/test-data/rdm-full.json new file mode 100644 index 0000000000..ec25750c6a --- /dev/null +++ b/tests/e2e/test-data/rdm-full.json @@ -0,0 +1,218 @@ +[ + { + "host": "localhost", + "port": 8100, + "name": "rdmHost+Port+Name", + "result": "success" + }, + { + "host": "localhost", + "port": 8101, + "name": "rdmHost+Port+Name+Username+Password", + "username": "rdmUsername", + "auth": "rdmAuth", + "result": "success" + }, + { + "host": "172.30.100.151", + "port": 6379, + "name": "rdmHost+Port+Name+ClusterTrue", + "cluster": true, + "result": "success" + }, + { + "host": "172.30.100.151", + "port": 6379, + "name": "rdmHost+Port+Name+ClusterFalse", + "cluster": false, + "result": "success" + }, + { + "host": "localhost", + "port": 8102, + "name": "rdmHost+Port+Name+Index", + "db": 2, + "result": "success" + }, + { + "host": "localhost", + "port": 8102, + "name": "rdmHost+Port+Name+otherFields", + "ssh_port": 22, + "timeout_connect": 60000, + "timeout_execute": 60000, + "other_field": "inv", + "result": "success" + }, + { + "host": "localhost", + "port": 8102, + "name": "rdmHost+Port+Name+CaCert", + "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/ca.crt", + "result": "success" + }, + { + "host": "localhost", + "port": 8102, + "name": "rdmHost+Port+Name+clientCert+privateKey", + "ssl": true, + "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", + "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "result": "success" + }, + { + "host": "localhost", + "port": 8102, + "name": "rdmHost+Port+Name+CaCert+clientCert+privateKey", + "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/ca.crt", + "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", + "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "result": "success" + }, + { + "host": "localhost", + "port": 8102, + "name": "rdmHost+Port+Name+CaCert+clientCert+privateKey(notbypath)", + "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/ca.crt", + "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", + "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "result": "success" + }, + { + "host": "172.30.100.103", + "port": 6379, + "name": "rdmHost+Port+Name+username+pass+CaCert+clientCert+privateKey", + "ssl": true, + "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/ca.crt", + "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", + "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "username": "admin", + "auth": "pass", + "result": "success" + }, + { + "host": "redis-13237.c263.us-east-1-2.ec2.cloud.redislabs.com", + "port": 13237, + "name": "rdmHost+Port+Name CloudDb", + "auth": "fRVdFcJftYnVoJmTCuiAPW6yu9qimXA1", + "result": "success" + }, + { + "host": "3.85.88.37", + "port": 14547, + "name": "rdmHost+Port+Name Cluster+CloudDb", + "auth": "Admin123!", + "result": "success" + }, + { + "host": "172.30.100.201", + "port": 6379, + "name": "rdmHost+Port+Name Sentinel", + "auth": "defaultpass", + "result": "success" + }, + { + "port": 8100, + "name": "rdmnoHost", + "result": "failed" + }, + { + "host": "localhost", + "name": "rdmnoPort", + "result": "failed" + }, + { + "name": "rdmnoHost+noPort", + "result": "failed" + }, + + { + "host": "rdmLargeName", + "port": "8101", + "name": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed consectetur metus in libero pretium congue. Curabitur eget eleifend nibh, cursus tincidunt lorem. Vivamus magna erat, vestibulum at sem et, bibendum volutpat velit. Cras dapibus lorem quam, at efficitur mi sollicitudin id. Vivamus dapibus nec elit ut tincidunt. Sed porta tempus lorem id iaculis. Vestibulum ut arcu vitae massa dapibus egestas. Suspendisse ante tortor, tristique vel malesuada id, finibus et libero. Nulla suscipit libero.1", + "result": "failed" + }, + { + "host": "localhost", + "port": 65536, + "name": "rdmLargePort", + "result": "failed" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmIndexNotNumber", + "db": "dsad", + "result": "failed" + }, + { + "host": "localhost", + "port": 8102, + "name": "rdmCaCertInvalidBody", + "ssl_ca_cert_path": "invalid body", + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmOnlyClientCert", + "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmOnlyPrivateKey", + "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmInvalidClientCert", + "ssl_local_cert_path": "invalid client cert", + "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmInvalidPrivateKey", + "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", + "ssl_private_key_path": "invalid private key", + "result": "partial" + }, + { + "auth": "", + "db_scan_limit": 21, + "host": "127.0.0.1", + "keys_pattern": "*1", + "lua_keys_loading": true, + "name": "rdmInvalidAllCertificates", + "namespace_separator": ":3", + "port": 8100, + "ssh_port": 22, + "ssl": false, + "ssl_ca_cert_path": "fdsafsadfsad", + "ssl_local_cert_path": "fsdfds", + "ssl_private_key_path": "fdsafasdf", + "timeout_connect": 61000, + "timeout_execute": 61000, + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmCaCertInvalidPath", + "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/caInvalid.crt", + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmClientCert+PrivateKeyInvalidPathes", + "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/clientInvalid.crt", + "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/clientInvalid.key", + "result": "partial" + } +] diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index ce738b9dac..d25b1da523 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -1,34 +1,49 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { rte } from '../../../helpers/constants'; import { AddRedisDatabasePage, BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; import { commonUrl } from '../../../helpers/conf'; import { acceptLicenseTerms, clickOnEditDatabaseByName } from '../../../helpers/database'; import { deleteStandaloneDatabasesByNamesApi } from '../../../helpers/api/api-database'; import { DatabasesActions } from '../../../common-actions/databases-actions'; +// import rdmJson from '../../../test-data/rdm-full.json' assert {type: 'json'}; +// import * as fil from '../../../test-data/rdm-full.json'; +const file = path.join('test-data', 'rdm-full.json'); const browserPage = new BrowserPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); const databasesActions = new DatabasesActions(); const addRedisDatabasePage = new AddRedisDatabasePage(); -const invalidJsonPath = '../../../test-data/racompass-invalid.json'; +const racompassValidJson = 'racompass-valid.json'; +const racompassInvalidJson = 'racompass-invalid.json'; +const rdmValidJson = 'rdm-valid.json'; +const rdmFullJson = 'rdm-full.json'; +const ardmValidAno = 'ardm-valid.ano'; +const listOfDB = JSON.parse(fs.readFileSync(file, 'utf-8')); +const dbSuccessNames = listOfDB.filter(element => element.result === 'success').map(item => item.name); + const rdmData = { type: 'rdm', - path: '../../../test-data/rdm-valid.json', + path: path.join('..', '..', '..', 'test-data', rdmFullJson), dbNames: ['rdmWithUsernameAndPass1:1561', 'rdmOnlyHostPortDB2:6379'], userName: 'rdmUsername', password: 'rdmAuth', connectionType: 'Cluster', - fileName: 'rdm-valid.json' + fileName: rdmFullJson, + successNumber: dbSuccessNames.length, + partialNumber: 8, + failedNumber: 6 }; const dbData = [ { type: 'racompass', - path: '../../../test-data/racompass-valid.json', + path: path.join('..', '..', '..', 'test-data', racompassValidJson), dbNames: ['racompassCluster', 'racompassDbWithIndex:8100 [1]'] }, { type: 'ardm', - path: '../../../test-data/ardm-valid.ano', + path: path.join('..', '..', '..', 'test-data', ardmValidAno), dbNames: ['ardmNoName:12001', 'ardmWithPassAndUsername'] } ]; @@ -54,8 +69,9 @@ test deleteStandaloneDatabasesByNamesApi(databases); })('Connection import from JSON', async t => { const tooltipText = 'Import Database Connections'; - const partialImportedMsg = 'Successfully added 2 of 6 database connections'; const defaultText = 'Select or drag and drop a file'; + const parseFailedMsg = 'Failed to add database connections'; + const parseFailedMsg2 = `Unable to parse ${racompassInvalidJson}`; // Verify that user can see the “Import Database Connections” tooltip await t.expect(myRedisDatabasePage.importDatabasesBtn.visible).ok('The import databases button not displayed'); @@ -70,9 +86,11 @@ test // Verify that user see the message when parse error appears await t - .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [invalidJsonPath]) + .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [path.join('..', '..', '..', 'test-data', racompassInvalidJson)]) .click(myRedisDatabasePage.submitImportBtn) - .expect(myRedisDatabasePage.failedImportMessage.exists).ok('Failed to add database message not displayed'); + .expect(myRedisDatabasePage.failedImportMessage.exists).ok('Failed to add database message not displayed') + .expect(myRedisDatabasePage.failedImportMessage.textContent).contains(parseFailedMsg) + .expect(myRedisDatabasePage.failedImportMessage.textContent).contains(parseFailedMsg2); // Verify that user can remove file from import input await t.click(myRedisDatabasePage.closeDialogBtn); @@ -87,8 +105,16 @@ test await t.click(myRedisDatabasePage.closeDialogBtn); await databasesActions.importDatabase(rdmData); // Verify that success message is displayed - await t.expect(myRedisDatabasePage.successImportMessage.textContent).contains(partialImportedMsg, 'Databases not imported successfully'); - + await t.expect(myRedisDatabasePage.successResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.successNumber}`, 'Not correct successfully imported number'); + await t.expect(myRedisDatabasePage.partialResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.partialNumber}`, 'Not correct partially imported number'); + await t.expect(myRedisDatabasePage.failedResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.failedNumber}`, 'Not correct failed to import number'); + + + + // Verify that list of databases is reloaded when database added await t.click(myRedisDatabasePage.okDialogBtn); await databasesActions.verifyDatabasesDisplayed(rdmData.dbNames); @@ -110,3 +136,16 @@ test await databasesActions.verifyDatabasesDisplayed(db.dbNames); } }); + test.only + .before(async() => { + await acceptLicenseTerms() + })('Connection import from JSON', async t => { + console.log(dbSuccessNames); + console.log(rdmData.successNumber); + + await databasesActions.importDatabase(rdmData); + // Verify that success message is displayed + await t.expect(myRedisDatabasePage.successResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.successNumber}`, 'Not correct successfully imported number'); + await databasesActions.verifyDatabasesDisplayed(dbSuccessNames); + }); From 77bc5dbfb0a789b9912256968cffd2b1a3c06f22 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Fri, 16 Dec 2022 16:15:55 +0100 Subject: [PATCH 48/57] added tests for show process results for importeed databases --- tests/e2e/docker.web.docker-compose.yml | 2 + tests/e2e/local.web.docker-compose.yml | 2 + tests/e2e/pageObjects/browser-page.ts | 2 - tests/e2e/test-data/certs/ca.crt | 3 + tests/e2e/test-data/certs/client.crt | 3 + tests/e2e/test-data/certs/client.key | 3 + tests/e2e/test-data/rdm-full.json | 41 ++-- tests/e2e/test-data/rdm-valid.json | 62 ------ .../database/import-databases.e2e.ts | 181 ++++++++---------- 9 files changed, 115 insertions(+), 184 deletions(-) create mode 100644 tests/e2e/test-data/certs/ca.crt create mode 100644 tests/e2e/test-data/certs/client.crt create mode 100644 tests/e2e/test-data/certs/client.key delete mode 100644 tests/e2e/test-data/rdm-valid.json diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index e93aca690f..ff4a10c0c1 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -12,6 +12,7 @@ services: - ./plugins:/usr/src/app/plugins - .redisinsight-v2:/root/.redisinsight-v2 - .ritmp:/tmp + - ./test-data/certs:/root/certs env_file: - ./.env entrypoint: [ @@ -40,4 +41,5 @@ services: volumes: - .redisinsight-v2:/root/.redisinsight-v2 - .ritmp:/tmp + - ./test-data/certs:/root/certs diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index ed864c84da..8e5dbb6eb9 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -10,6 +10,7 @@ services: - ./results:/usr/src/app/results - ./plugins:/usr/src/app/plugins - .redisinsight-v2:/root/.redisinsight-v2 + - ./test-data/certs:/root/certs env_file: - ./.env environment: @@ -41,5 +42,6 @@ services: dockerfile: Dockerfile volumes: - .redisinsight-v2:/root/.redisinsight-v2 + - ./test-data/certs:/root/certs ports: - 5000:5000 diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index b1a768f8ae..3d6099c935 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -26,8 +26,6 @@ export class BrowserPage { setDeleteButton = Selector('[data-testid=set-delete-btn]'); streamDeleteButton = Selector('[data-testid=stream-delete-btn]'); myRedisDbIcon = Selector('[data-testid=my-redis-db-icon]'); - streamDeleteButton = Selector('[data-testid=stream-delete-btn]'); - myRedisDbIcon = Selector('[data-testid=my-redis-db-icon]'); deleteKeyButton = Selector('[data-testid=delete-key-btn]'); confirmDeleteKeyButton = Selector('[data-testid=delete-key-confirm-btn]'); editKeyTTLButton = Selector('[data-testid=edit-ttl-btn]'); diff --git a/tests/e2e/test-data/certs/ca.crt b/tests/e2e/test-data/certs/ca.crt new file mode 100644 index 0000000000..0722ac4149 --- /dev/null +++ b/tests/e2e/test-data/certs/ca.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +mockedCACertificate +-----END CERTIFICATE----- diff --git a/tests/e2e/test-data/certs/client.crt b/tests/e2e/test-data/certs/client.crt new file mode 100644 index 0000000000..43dee13d0a --- /dev/null +++ b/tests/e2e/test-data/certs/client.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +mockedClientCrt +-----END CERTIFICATE----- diff --git a/tests/e2e/test-data/certs/client.key b/tests/e2e/test-data/certs/client.key new file mode 100644 index 0000000000..d3f63edc85 --- /dev/null +++ b/tests/e2e/test-data/certs/client.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +mockedPrivateKey +-----END PRIVATE KEY----- diff --git a/tests/e2e/test-data/rdm-full.json b/tests/e2e/test-data/rdm-full.json index ec25750c6a..6e7a00af9d 100644 --- a/tests/e2e/test-data/rdm-full.json +++ b/tests/e2e/test-data/rdm-full.json @@ -32,7 +32,8 @@ "port": 8102, "name": "rdmHost+Port+Name+Index", "db": 2, - "result": "success" + "result": "success", + "indName": "rdmHost+Port+Name+Index [2]" }, { "host": "localhost", @@ -48,7 +49,7 @@ "host": "localhost", "port": 8102, "name": "rdmHost+Port+Name+CaCert", - "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/ca.crt", + "ssl_ca_cert_path": "/root/certs/ca.crt", "result": "success" }, { @@ -56,26 +57,26 @@ "port": 8102, "name": "rdmHost+Port+Name+clientCert+privateKey", "ssl": true, - "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", - "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "ssl_local_cert_path": "/root/certs/client.crt", + "ssl_private_key_path": "/root/certs/client.key", "result": "success" }, { "host": "localhost", "port": 8102, "name": "rdmHost+Port+Name+CaCert+clientCert+privateKey", - "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/ca.crt", - "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", - "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "ssl_ca_cert_path": "/root/certs/ca.crt", + "ssl_local_cert_path": "/root/certs/client.crt", + "ssl_private_key_path": "/root/certs/client.key", "result": "success" }, { "host": "localhost", "port": 8102, "name": "rdmHost+Port+Name+CaCert+clientCert+privateKey(notbypath)", - "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/ca.crt", - "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", - "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "ssl_ca_cert_path": "/root/certs/ca.crt", + "ssl_local_cert_path": "/root/certs/client.crt", + "ssl_private_key_path": "/root/certs/client.key", "result": "success" }, { @@ -83,9 +84,9 @@ "port": 6379, "name": "rdmHost+Port+Name+username+pass+CaCert+clientCert+privateKey", "ssl": true, - "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/ca.crt", - "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", - "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "ssl_ca_cert_path": "/root/certs/ca.crt", + "ssl_local_cert_path": "/root/certs/client.crt", + "ssl_private_key_path": "/root/certs/client.key", "username": "admin", "auth": "pass", "result": "success" @@ -156,14 +157,14 @@ "host": "localhost", "port": 8103, "name": "rdmOnlyClientCert", - "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", + "ssl_local_cert_path": "/root/certs/client.crt", "result": "partial" }, { "host": "localhost", "port": 8103, "name": "rdmOnlyPrivateKey", - "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "ssl_private_key_path": "/root/certs/client.key", "result": "partial" }, { @@ -171,14 +172,14 @@ "port": 8103, "name": "rdmInvalidClientCert", "ssl_local_cert_path": "invalid client cert", - "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.key", + "ssl_private_key_path": "/root/certs/client.key", "result": "partial" }, { "host": "localhost", "port": 8103, "name": "rdmInvalidPrivateKey", - "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/client.crt", + "ssl_local_cert_path": "/root/certs/client.crt", "ssl_private_key_path": "invalid private key", "result": "partial" }, @@ -204,15 +205,15 @@ "host": "localhost", "port": 8103, "name": "rdmCaCertInvalidPath", - "ssl_ca_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/caInvalid.crt", + "ssl_ca_cert_path": "/root/certs/caInvalid.crt", "result": "partial" }, { "host": "localhost", "port": 8103, "name": "rdmClientCert+PrivateKeyInvalidPathes", - "ssl_local_cert_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/clientInvalid.crt", - "ssl_private_key_path": "C:/Projects/redisinsight-redis/RedisInsight/tests/e2e/.redisinsight-v2/clientInvalid.key", + "ssl_local_cert_path": "/root/certs/clientInvalid.crt", + "ssl_private_key_path": "/root/certs/clientInvalid.key", "result": "partial" } ] diff --git a/tests/e2e/test-data/rdm-valid.json b/tests/e2e/test-data/rdm-valid.json deleted file mode 100644 index 845fe507e7..0000000000 --- a/tests/e2e/test-data/rdm-valid.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "password": "new", - "filter_history": { - "*": 1, - "stream1": 1 - }, - "host": "localhost", - "name": "vfd das jashd ashdkjh kjhasfh hfjks dahfjk shdk fhskjad hfkj sdhkj fashk dhfk sahkj fhsak dhfskja hsd kfjh dsakj fhsdjk fhskdjal fhksjd hfkjsd hfkjs hakdjfh sjkdah fjksdh fjksdh jkfh ksdh fsdkjhfksjhfkhsdkf hksjdhf kjsdhf skdhf sdf sdfsda fsdfsd fsd fsdf sd f sdf sd f sd fsd f sd f sd fsd f sad fs df sd f s dsa fsdf vfd das jashd ashdkjh", - "ssh_port": 22, - "timeout_connect": 60000, - "timeout_execute": 60000, - "cluster": false, - "invalid_field": "inv", - "db": 0 - }, - { - "host": "rdmWithUsernameAndPass1", - "port": "1561", - "cluster": true, - "username": "rdmUsername", - "auth": "rdmAuth" - }, - { - "auth": "", - "host": "172.30.100.151", - "name": "oss cluster", - "ssh_port": 22, - "cluster": true, - "timeout_connect": 60000, - "timeout_execute": 60000 - }, - { - "auth": "longname database >500 symbols invalid", - "host": "172.30.100.181", - "port": 1000, - "keys_pattern": "*", - "name": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla rutrum nec libero aliquet ultricies. Donec ut blandit dui, ac hendrerit risus. Vestibulum sodales sed risus ac auctor. Integer quis justo vel leo gravida volutpat quis et risus. Nam vestibulum fermentum eros, in ullamcorper nulla laoreet quis. Vestibulum ut arcu nec turpis elementum malesuada. Maecenas congue felis nec posuere ullamcorper. Phasellus auctor leo sit amet ligula scelerisque, quis elementum ligula auctor. Quisque id leo r", - "namespace_separator": ":", - "ssh_port": 22, - "ssl": true, - "ssl_ca_cert_path": "E:/Redis/redisinsight-envs/cluster-tls-client/certs/ca.crt", - "ssl_local_cert_path": "E:/Redis/redisinsight-envs/cluster-tls-client/certs/client.crt", - "ssl_private_key_path": "E:/Redis/redisinsight-envs/cluster-tls-client/certs/client.key", - "timeout_connect": 60000, - "timeout_execute": 60000 - }, - { - "auth": "pass", - "host": "localhost", - "name": "plain-certs+pass", - "port": 65537, - "ssh_port": 22, - "ssl_local_cert_path": "C:/Users/enaboko/Downloads/certificates", - "timeout_connect": 60000, - "timeout_execute": 60000 - }, - { - "host": "rdmOnlyHostPortDB2", - "port": 6379 - } -] diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index d25b1da523..79812dd615 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -6,9 +6,6 @@ import { commonUrl } from '../../../helpers/conf'; import { acceptLicenseTerms, clickOnEditDatabaseByName } from '../../../helpers/database'; import { deleteStandaloneDatabasesByNamesApi } from '../../../helpers/api/api-database'; import { DatabasesActions } from '../../../common-actions/databases-actions'; -// import rdmJson from '../../../test-data/rdm-full.json' assert {type: 'json'}; -// import * as fil from '../../../test-data/rdm-full.json'; -const file = path.join('test-data', 'rdm-full.json'); const browserPage = new BrowserPage(); const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -17,23 +14,20 @@ const addRedisDatabasePage = new AddRedisDatabasePage(); const racompassValidJson = 'racompass-valid.json'; const racompassInvalidJson = 'racompass-invalid.json'; -const rdmValidJson = 'rdm-valid.json'; const rdmFullJson = 'rdm-full.json'; const ardmValidAno = 'ardm-valid.ano'; -const listOfDB = JSON.parse(fs.readFileSync(file, 'utf-8')); +const listOfDB = JSON.parse(fs.readFileSync(path.join('test-data', 'rdm-full.json'), 'utf-8')); const dbSuccessNames = listOfDB.filter(element => element.result === 'success').map(item => item.name); - +const dbPartialNames = listOfDB.filter(element => element.result === 'partial').map(item => item.name); +const dbFailedNames = listOfDB.filter(element => element.result === 'failed').map(item => item.name); +const dbImportedNames = [...dbSuccessNames, ...dbPartialNames]; const rdmData = { type: 'rdm', path: path.join('..', '..', '..', 'test-data', rdmFullJson), - dbNames: ['rdmWithUsernameAndPass1:1561', 'rdmOnlyHostPortDB2:6379'], - userName: 'rdmUsername', - password: 'rdmAuth', connectionType: 'Cluster', - fileName: rdmFullJson, successNumber: dbSuccessNames.length, - partialNumber: 8, - failedNumber: 6 + partialNumber: dbPartialNames.length, + failedNumber: dbFailedNames.length }; const dbData = [ { @@ -49,103 +43,90 @@ const dbData = [ ]; // List of all created databases to delete const databases = [ - rdmData.dbNames[0], - rdmData.dbNames[1], dbData[0].dbNames[0], dbData[0].dbNames[1].split(' ')[0], dbData[1].dbNames[0], dbData[1].dbNames[1] ]; +const databasesToDelete = [...dbImportedNames, ...databases]; fixture `Import databases` .meta({ type: 'critical_path', rte: rte.standalone }) - .page(commonUrl); -test - .before(async() => { + .page(commonUrl) + .beforeEach(async() => { await acceptLicenseTerms(); }) - .after(async() => { + .afterEach(async() => { // Delete databases - deleteStandaloneDatabasesByNamesApi(databases); - })('Connection import from JSON', async t => { - const tooltipText = 'Import Database Connections'; - const defaultText = 'Select or drag and drop a file'; - const parseFailedMsg = 'Failed to add database connections'; - const parseFailedMsg2 = `Unable to parse ${racompassInvalidJson}`; - - // Verify that user can see the “Import Database Connections” tooltip - await t.expect(myRedisDatabasePage.importDatabasesBtn.visible).ok('The import databases button not displayed'); - await t.hover(myRedisDatabasePage.importDatabasesBtn); - await t.expect(browserPage.tooltip.innerText).contains(tooltipText, 'The tooltip message not displayed/correct'); - - // Verify that Import dialogue is not closed when clicking any area outside the box - await t.click(myRedisDatabasePage.importDatabasesBtn); - await t.expect(myRedisDatabasePage.importDbDialog.exists).ok('Import Database Connections dialog not opened'); - await t.click(myRedisDatabasePage.myRedisDBButton); - await t.expect(myRedisDatabasePage.importDbDialog.exists).ok('Import Database Connections dialog not displayed'); - - // Verify that user see the message when parse error appears - await t - .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [path.join('..', '..', '..', 'test-data', racompassInvalidJson)]) - .click(myRedisDatabasePage.submitImportBtn) - .expect(myRedisDatabasePage.failedImportMessage.exists).ok('Failed to add database message not displayed') - .expect(myRedisDatabasePage.failedImportMessage.textContent).contains(parseFailedMsg) - .expect(myRedisDatabasePage.failedImportMessage.textContent).contains(parseFailedMsg2); - - // Verify that user can remove file from import input - await t.click(myRedisDatabasePage.closeDialogBtn); - await t.click(myRedisDatabasePage.importDatabasesBtn); - await t.setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [rdmData.path]); - await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(rdmData.fileName, 'Filename not displayed in import input'); - // Click on remove button - await t.click(myRedisDatabasePage.removeImportedFileBtn); - await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(defaultText, 'File not removed from import input'); - - // Verify that user can import database with mandatory fields - await t.click(myRedisDatabasePage.closeDialogBtn); - await databasesActions.importDatabase(rdmData); - // Verify that success message is displayed - await t.expect(myRedisDatabasePage.successResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) - .contains(`${rdmData.successNumber}`, 'Not correct successfully imported number'); - await t.expect(myRedisDatabasePage.partialResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) - .contains(`${rdmData.partialNumber}`, 'Not correct partially imported number'); - await t.expect(myRedisDatabasePage.failedResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) - .contains(`${rdmData.failedNumber}`, 'Not correct failed to import number'); - - - - - // Verify that list of databases is reloaded when database added - await t.click(myRedisDatabasePage.okDialogBtn); - await databasesActions.verifyDatabasesDisplayed(rdmData.dbNames); - - // Verify that user can import database with mandatory+optional data - await clickOnEditDatabaseByName(rdmData.dbNames[0]); - // Verify username imported - await t.expect(addRedisDatabasePage.usernameInput.value).eql(rdmData.userName); - // Verify password imported - await t.click(addRedisDatabasePage.showPasswordBtn); - await t.expect(addRedisDatabasePage.passwordInput.value).eql(rdmData.password); - // Verify cluster connection type imported - await t.expect(addRedisDatabasePage.connectionType.textContent).eql(rdmData.connectionType); - - // Verify that user can import files from Racompass, ARDM, RDM - for (const db of dbData) { - await databasesActions.importDatabase(db); - await t.click(myRedisDatabasePage.okDialogBtn); - await databasesActions.verifyDatabasesDisplayed(db.dbNames); - } - }); - test.only - .before(async() => { - await acceptLicenseTerms() - })('Connection import from JSON', async t => { - console.log(dbSuccessNames); - console.log(rdmData.successNumber); - - await databasesActions.importDatabase(rdmData); - // Verify that success message is displayed - await t.expect(myRedisDatabasePage.successResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) - .contains(`${rdmData.successNumber}`, 'Not correct successfully imported number'); - await databasesActions.verifyDatabasesDisplayed(dbSuccessNames); + deleteStandaloneDatabasesByNamesApi(databasesToDelete); }); +test('Connection import from JSON', async t => { + const tooltipText = 'Import Database Connections'; + const defaultText = 'Select or drag and drop a file'; + const parseFailedMsg = 'Failed to add database connections'; + const parseFailedMsg2 = `Unable to parse ${racompassInvalidJson}`; + + // Verify that user can see the “Import Database Connections” tooltip + await t.expect(myRedisDatabasePage.importDatabasesBtn.visible).ok('The import databases button not displayed'); + await t.hover(myRedisDatabasePage.importDatabasesBtn); + await t.expect(browserPage.tooltip.innerText).contains(tooltipText, 'The tooltip message not displayed/correct'); + + // Verify that Import dialogue is not closed when clicking any area outside the box + await t.click(myRedisDatabasePage.importDatabasesBtn); + await t.expect(myRedisDatabasePage.importDbDialog.exists).ok('Import Database Connections dialog not opened'); + await t.click(myRedisDatabasePage.myRedisDBButton); + await t.expect(myRedisDatabasePage.importDbDialog.exists).ok('Import Database Connections dialog not displayed'); + + // Verify that user see the message when parse error appears + await t + .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [path.join('..', '..', '..', 'test-data', racompassInvalidJson)]) + .click(myRedisDatabasePage.submitImportBtn) + .expect(myRedisDatabasePage.failedImportMessage.exists).ok('Failed to add database message not displayed') + .expect(myRedisDatabasePage.failedImportMessage.textContent).contains(parseFailedMsg) + .expect(myRedisDatabasePage.failedImportMessage.textContent).contains(parseFailedMsg2); + + // Verify that user can remove file from import input + await t.click(myRedisDatabasePage.closeDialogBtn); + await t.click(myRedisDatabasePage.importDatabasesBtn); + await t.setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [rdmData.path]); + await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(rdmFullJson, 'Filename not displayed in import input'); + // Click on remove button + await t.click(myRedisDatabasePage.removeImportedFileBtn); + await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(defaultText, 'File not removed from import input'); + + // Verify that user can import database with mandatory/optional fields + await t.click(myRedisDatabasePage.closeDialogBtn); + await databasesActions.importDatabase(rdmData); + + // Fully imported table + await t.expect(myRedisDatabasePage.successResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.successNumber}`, 'Not correct successfully imported number'); + // Partially imported table + await t.expect(myRedisDatabasePage.partialResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.partialNumber}`, 'Not correct partially imported number'); + // Failed to import table + await t.expect(myRedisDatabasePage.failedResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.failedNumber}`, 'Not correct import failed number'); + + // Verify that list of databases is reloaded when database added + await t.click(myRedisDatabasePage.okDialogBtn); + await databasesActions.verifyDatabasesDisplayed(dbImportedNames); + + await clickOnEditDatabaseByName(dbImportedNames[1]); + // Verify username imported + await t.expect(addRedisDatabasePage.usernameInput.value).eql(listOfDB[1].username); + // Verify password imported + await t.click(addRedisDatabasePage.showPasswordBtn); + await t.expect(addRedisDatabasePage.passwordInput.value).eql(listOfDB[1].auth); + + // Verify cluster connection type imported + await clickOnEditDatabaseByName(dbImportedNames[2]); + await t.expect(addRedisDatabasePage.connectionType.textContent).eql(rdmData.connectionType); + + // Verify that user can import files from Racompass, ARDM, RDM + for (const db of dbData) { + await databasesActions.importDatabase(db); + await t.click(myRedisDatabasePage.okDialogBtn); + await databasesActions.verifyDatabasesDisplayed(db.dbNames); + } +}); From 25e1e92f06d6af56b4110f051de13af218f5d8bb Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 19 Dec 2022 09:15:18 +0200 Subject: [PATCH 49/57] all tests --- redisinsight/api/src/__mocks__/common.ts | 1 + .../api/src/__mocks__/database-import.ts | 39 +- ...ocal.client-certificate.repository.spec.ts | 1 - .../certificate-import.service.spec.ts | 341 ++++ .../database-import.analytics.spec.ts | 8 +- .../database-import.service.spec.ts | 16 +- .../database-import.service.ts | 6 +- .../POST-databases-import.test.ts | 1386 ++++++++++++++++- redisinsight/api/test/helpers/constants.ts | 7 + redisinsight/api/test/helpers/redis.ts | 10 + .../api/test/test-runs/docker.build.env | 1 + .../api/test/test-runs/docker.build.yml | 2 + .../api/test/test-runs/local.build.env | 1 + .../api/test/test-runs/local.build.yml | 2 + 14 files changed, 1754 insertions(+), 67 deletions(-) create mode 100644 redisinsight/api/src/modules/database-import/certificate-import.service.spec.ts diff --git a/redisinsight/api/src/__mocks__/common.ts b/redisinsight/api/src/__mocks__/common.ts index 466a8bd0fd..c4844f6c3b 100644 --- a/redisinsight/api/src/__mocks__/common.ts +++ b/redisinsight/api/src/__mocks__/common.ts @@ -32,6 +32,7 @@ export const mockQueryBuilderExecute = jest.fn(); export const mockCreateQueryBuilder = jest.fn(() => ({ // where: jest.fn().mockReturnThis(), where: mockQueryBuilderWhere, + orWhere: mockQueryBuilderWhere, update: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), diff --git a/redisinsight/api/src/__mocks__/database-import.ts b/redisinsight/api/src/__mocks__/database-import.ts index e15d864f41..fc8c44777a 100644 --- a/redisinsight/api/src/__mocks__/database-import.ts +++ b/redisinsight/api/src/__mocks__/database-import.ts @@ -1,9 +1,13 @@ import { DatabaseImportResponse, DatabaseImportStatus } from 'src/modules/database-import/dto/database-import.response'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; -import { mockDatabase } from 'src/__mocks__/databases'; +import { mockDatabase, mockSentinelDatabaseWithTlsAuth } from 'src/__mocks__/databases'; import { ValidationException } from 'src/common/exceptions'; +import { mockCaCertificate, mockClientCertificate } from 'src/__mocks__/certificates'; +import { + InvalidCaCertificateBodyException, InvalidCertificateNameException, +} from 'src/modules/database-import/exceptions'; -export const mockDatabasesToImportArray = new Array(10).fill(mockDatabase); +export const mockDatabasesToImportArray = new Array(10).fill(mockSentinelDatabaseWithTlsAuth); export const mockDatabaseImportFile = { originalname: 'filename.json', @@ -27,13 +31,28 @@ export const mockDatabaseImportResultFail = { errors: [new BadRequestException()], }; +export const mockDatabaseImportResultPartial = { + index: 0, + status: DatabaseImportStatus.Partial, + host: mockDatabase.host, + port: mockDatabase.port, + errors: [new InvalidCaCertificateBodyException()], +}; + export const mockDatabaseImportResponse = Object.assign(new DatabaseImportResponse(), { total: 10, - success: (new Array(7).fill(mockDatabaseImportResultSuccess)).map((v, index) => ({ + success: (new Array(5).fill(mockDatabaseImportResultSuccess)).map((v, index) => ({ ...v, + index: index + 5, + })), + partial: [ + [new InvalidCaCertificateBodyException(), new InvalidCertificateNameException()], + [new InvalidCertificateNameException()], + ].map((errors, index) => ({ + ...mockDatabaseImportResultPartial, index: index + 3, + errors, })), - partial: [], fail: [ new ValidationException('Bad request'), new BadRequestException(), @@ -45,8 +64,9 @@ export const mockDatabaseImportResponse = Object.assign(new DatabaseImportRespon })), }); -export const mockDatabaseImportParseFailedAnalyticsPayload = { - +export const mockDatabaseImportPartialAnalyticsPayload = { + partially: mockDatabaseImportResponse.partial.length, + errors: ['InvalidCaCertificateBodyException', 'InvalidCertificateNameException'], }; export const mockDatabaseImportFailedAnalyticsPayload = { @@ -63,6 +83,7 @@ export const mockDatabaseImportAnalytics = jest.fn(() => ({ sendImportFailed: jest.fn(), })); -export const mockCertificateImportService = jest.fn(() => { - -}); +export const mockCertificateImportService = jest.fn(() => ({ + processCaCertificate: jest.fn().mockResolvedValue(mockCaCertificate), + processClientCertificate: jest.fn().mockResolvedValue(mockClientCertificate), +})); diff --git a/redisinsight/api/src/modules/certificate/repositories/local.client-certificate.repository.spec.ts b/redisinsight/api/src/modules/certificate/repositories/local.client-certificate.repository.spec.ts index ba88c967bb..6847becd99 100644 --- a/redisinsight/api/src/modules/certificate/repositories/local.client-certificate.repository.spec.ts +++ b/redisinsight/api/src/modules/certificate/repositories/local.client-certificate.repository.spec.ts @@ -15,7 +15,6 @@ import { mockRepository, MockType, } from 'src/__mocks__'; -import { LocalCaCertificateRepository } from 'src/modules/certificate/repositories/local.ca-certificate.repository'; import { EncryptionService } from 'src/modules/encryption/encryption.service'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import ERROR_MESSAGES from 'src/constants/error-messages'; diff --git a/redisinsight/api/src/modules/database-import/certificate-import.service.spec.ts b/redisinsight/api/src/modules/database-import/certificate-import.service.spec.ts new file mode 100644 index 0000000000..2939250c52 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/certificate-import.service.spec.ts @@ -0,0 +1,341 @@ +import { when } from 'jest-when'; +import { + mockCaCertificate, + mockCaCertificateCertificateEncrypted, + mockCaCertificateCertificatePlain, + mockCaCertificateEntity, + mockClientCertificate, + mockClientCertificateCertificateEncrypted, + mockClientCertificateCertificatePlain, + mockClientCertificateEntity, + mockClientCertificateKeyEncrypted, + mockClientCertificateKeyPlain, + mockEncryptionService, + mockRepository, + MockType, +} from 'src/__mocks__'; +import * as utils from 'src/common/utils'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + InvalidCaCertificateBodyException, + InvalidCertificateNameException, + InvalidClientCertificateBodyException, + InvalidClientPrivateKeyException, +} from 'src/modules/database-import/exceptions'; +import { CertificateImportService } from 'src/modules/database-import/certificate-import.service'; +import { EncryptionService } from 'src/modules/encryption/encryption.service'; +import { Repository } from 'typeorm'; +import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity'; +import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +jest.mock('src/common/utils', () => ({ + ...jest.requireActual('src/common/utils') as object, + getPemBodyFromFileSync: jest.fn(), +})); + +describe('CertificateImportService', () => { + let service: CertificateImportService; + let caRepository: MockType>; + let clientRepository: MockType>; + let encryptionService: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CertificateImportService, + { + provide: getRepositoryToken(CaCertificateEntity), + useFactory: mockRepository, + }, + { + provide: getRepositoryToken(ClientCertificateEntity), + useFactory: mockRepository, + }, + { + provide: EncryptionService, + useFactory: mockEncryptionService, + }, + ], + }).compile(); + + service = await module.get(CertificateImportService); + caRepository = await module.get(getRepositoryToken(CaCertificateEntity)); + clientRepository = await module.get(getRepositoryToken(ClientCertificateEntity)); + encryptionService = await module.get(EncryptionService); + + when(encryptionService.decrypt).calledWith(mockCaCertificateCertificateEncrypted, jasmine.anything()) + .mockResolvedValue(mockCaCertificateCertificatePlain); + when(encryptionService.encrypt).calledWith(mockCaCertificateCertificatePlain) + .mockResolvedValue({ + data: mockCaCertificateCertificateEncrypted, + encryption: mockCaCertificateEntity.encryption, + }); + + when(encryptionService.decrypt) + .calledWith(mockClientCertificateCertificateEncrypted, jasmine.anything()) + .mockResolvedValue(mockClientCertificateCertificatePlain) + .calledWith(mockClientCertificateKeyEncrypted, jasmine.anything()) + .mockResolvedValue(mockClientCertificateKeyPlain); + when(encryptionService.encrypt) + .calledWith(mockClientCertificateCertificatePlain) + .mockResolvedValue({ + data: mockClientCertificateCertificateEncrypted, + encryption: mockClientCertificateEntity.encryption, + }) + .calledWith(mockClientCertificateKeyPlain) + .mockResolvedValue({ + data: mockClientCertificateKeyEncrypted, + encryption: mockClientCertificateEntity.encryption, + }); + }); + + let determineAvailableNameSpy; + let getPemBodyFromFileSyncSpy; + let prepareCaCertificateForImportSpy; + let prepareClientCertificateForImportSpy; + + describe('processCaCertificate', () => { + beforeEach(() => { + getPemBodyFromFileSyncSpy = jest.spyOn(utils as any, 'getPemBodyFromFileSync'); + getPemBodyFromFileSyncSpy.mockReturnValue(mockCaCertificate.certificate); + prepareCaCertificateForImportSpy = jest.spyOn(service as any, 'prepareCaCertificateForImport'); + prepareCaCertificateForImportSpy.mockResolvedValueOnce(mockCaCertificate); + }); + + it('should successfully process certificate', async () => { + const response = await service['processCaCertificate']({ + name: mockCaCertificate.name, + certificate: mockCaCertificate.certificate, + }); + + expect(response).toEqual(mockCaCertificate); + }); + + it('should fail when no name defined', async () => { + try { + await service['processCaCertificate']({ + name: undefined, + certificate: mockCaCertificate.certificate, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InvalidCertificateNameException); + } + }); + + it('should successfully process certificate from file', async () => { + const response = await service['processCaCertificate']({ + certificate: '/path/ca.crt', + }); + + expect(response).toEqual(mockCaCertificate); + expect(prepareCaCertificateForImportSpy).toHaveBeenCalledWith({ + name: 'ca', + certificate: mockCaCertificate.certificate, + }); + }); + + it('should fail when no file found', async () => { + getPemBodyFromFileSyncSpy.mockImplementationOnce(() => { throw new Error(); }); + + try { + await service['processCaCertificate']({ + name: undefined, + certificate: '/path/ca.crt', + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InvalidCaCertificateBodyException); + } + }); + }); + + describe('prepareCaCertificateForImport', () => { + beforeEach(() => { + determineAvailableNameSpy = jest.spyOn(CertificateImportService, 'determineAvailableName'); + }); + + it('should return existing certificate', async () => { + caRepository.createQueryBuilder().getOne.mockResolvedValueOnce(mockCaCertificate); + + const response = await service['prepareCaCertificateForImport']({ + name: mockCaCertificate.name, + certificate: mockCaCertificate.certificate, + }); + + expect(response).toEqual(mockCaCertificate); + expect(determineAvailableNameSpy).not.toHaveBeenCalled(); + }); + + it('should return new certificate', async () => { + caRepository.createQueryBuilder().getOne.mockResolvedValueOnce(null); // for cert search + caRepository.createQueryBuilder().getOne.mockResolvedValueOnce(null); // for name search + + const response = await service['prepareCaCertificateForImport']({ + name: `${mockCaCertificate.name}_new`, + certificate: mockCaCertificate.certificate, + }); + + expect(response).toEqual({ + ...mockCaCertificate, + id: undefined, // return not-existing model + name: `${mockCaCertificate.name}_new`, + }); + }); + + it('should generate name with prefix', async () => { + caRepository.createQueryBuilder().getOne.mockResolvedValueOnce(null); // for cert search + caRepository.createQueryBuilder().getOne.mockResolvedValueOnce(mockCaCertificate); // for name search 1st attempt + caRepository.createQueryBuilder().getOne.mockResolvedValueOnce(mockCaCertificate); // for name search 2nd attempt + caRepository.createQueryBuilder().getOne.mockResolvedValueOnce(null); // for name search 3rd attempt + + const response = await service['prepareCaCertificateForImport']({ + name: `${mockCaCertificate.name}_new`, + certificate: mockCaCertificate.certificate, + }); + + expect(response).toEqual({ + ...mockCaCertificate, + id: undefined, // return not-existing model + name: `2_${mockCaCertificate.name}_new`, + }); + }); + }); + + describe('processClientCertificate', () => { + beforeEach(() => { + getPemBodyFromFileSyncSpy = jest.spyOn(utils as any, 'getPemBodyFromFileSync'); + prepareClientCertificateForImportSpy = jest.spyOn(service as any, 'prepareClientCertificateForImport'); + prepareClientCertificateForImportSpy.mockResolvedValueOnce(mockClientCertificate); + }); + + it('should successfully process client certificate', async () => { + const response = await service['processClientCertificate']({ + name: mockClientCertificate.name, + certificate: mockClientCertificate.certificate, + key: mockClientCertificate.key, + }); + + expect(response).toEqual(mockClientCertificate); + }); + + it('should fail when no name defined', async () => { + try { + await service['processClientCertificate']({ + name: undefined, + certificate: mockClientCertificate.certificate, + key: mockClientCertificate.key, + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InvalidCertificateNameException); + } + }); + + it('should successfully process certificate from file', async () => { + getPemBodyFromFileSyncSpy.mockReturnValueOnce(mockClientCertificate.certificate); + getPemBodyFromFileSyncSpy.mockReturnValueOnce(mockClientCertificate.key); + + const response = await service['processClientCertificate']({ + certificate: '/path/client.crt', + key: '/path/key.key', + }); + + expect(response).toEqual(mockClientCertificate); + expect(prepareClientCertificateForImportSpy).toHaveBeenCalledWith({ + name: 'client', + certificate: mockClientCertificate.certificate, + key: mockClientCertificate.key, + }); + }); + + it('should fail when no cert file found', async () => { + getPemBodyFromFileSyncSpy.mockImplementationOnce(() => { throw new Error(); }); + + try { + await service['processClientCertificate']({ + name: undefined, + certificate: '/path/client1.crt', + key: '/path/key1.key', + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InvalidClientCertificateBodyException); + } + }); + + it('should fail when no key file found', async () => { + getPemBodyFromFileSyncSpy.mockReturnValueOnce(mockClientCertificate.certificate); + getPemBodyFromFileSyncSpy.mockImplementationOnce(() => { throw new Error(); }); + + try { + await service['processClientCertificate']({ + name: undefined, + certificate: '/path/client.crt', + key: '/path/key.key', + }); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InvalidClientPrivateKeyException); + } + }); + }); + + describe('prepareClientCertificateForImport', () => { + beforeEach(() => { + determineAvailableNameSpy = jest.spyOn(CertificateImportService, 'determineAvailableName'); + }); + + it('should return existing certificate', async () => { + clientRepository.createQueryBuilder().getOne.mockResolvedValueOnce(mockClientCertificate); + + const response = await service['prepareClientCertificateForImport']({ + name: mockClientCertificate.name, + certificate: mockClientCertificate.certificate, + key: mockClientCertificate.key, + }); + + expect(response).toEqual(mockClientCertificate); + expect(determineAvailableNameSpy).not.toHaveBeenCalled(); + }); + + it('should return new certificate', async () => { + clientRepository.createQueryBuilder().getOne.mockResolvedValueOnce(null); // for cert search + clientRepository.createQueryBuilder().getOne.mockResolvedValueOnce(null); // for name search + + const response = await service['prepareClientCertificateForImport']({ + name: `${mockClientCertificate.name}_new`, + certificate: mockClientCertificate.certificate, + key: mockClientCertificate.key, + }); + + expect(response).toEqual({ + ...mockClientCertificate, + id: undefined, // return not-existing model + name: `${mockClientCertificate.name}_new`, + }); + }); + + it('should generate name with prefix', async () => { + clientRepository.createQueryBuilder().getOne.mockResolvedValueOnce(null); // for cert search + clientRepository.createQueryBuilder().getOne.mockResolvedValueOnce(mockClientCertificate); // name 1st attempt + clientRepository.createQueryBuilder().getOne.mockResolvedValueOnce(mockClientCertificate); // name 2nd attempt + clientRepository.createQueryBuilder().getOne.mockResolvedValueOnce(null); // name 3rd attempt + + const response = await service['prepareClientCertificateForImport']({ + name: `${mockClientCertificate.name}_new`, + certificate: mockClientCertificate.certificate, + key: mockClientCertificate.key, + }); + + expect(response).toEqual({ + ...mockClientCertificate, + id: undefined, // return not-existing model + name: `2_${mockClientCertificate.name}_new`, + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts b/redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts index 920cc215f4..a2554d742e 100644 --- a/redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts +++ b/redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { - mockDatabaseImportFailedAnalyticsPayload, + mockDatabaseImportFailedAnalyticsPayload, mockDatabaseImportPartialAnalyticsPayload, mockDatabaseImportResponse, mockDatabaseImportSucceededAnalyticsPayload, } from 'src/__mocks__'; import { TelemetryEvents } from 'src/constants'; @@ -44,6 +44,12 @@ describe('DatabaseImportAnalytics', () => { TelemetryEvents.DatabaseImportFailed, mockDatabaseImportFailedAnalyticsPayload, ); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 3, + TelemetryEvents.DatabaseImportPartiallySucceeded, + mockDatabaseImportPartialAnalyticsPayload, + ); }); }); diff --git a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts index 3f6fa114a2..45a4957e33 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.spec.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts @@ -15,14 +15,16 @@ import { ConnectionType } from 'src/modules/database/entities/database.entity'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { ValidationError } from 'class-validator'; import { + InvalidCaCertificateBodyException, InvalidCertificateNameException, InvalidClientCertificateBodyException, NoDatabaseImportFileProvidedException, SizeLimitExceededDatabaseImportFileException, - UnableToParseDatabaseImportFileException, + UnableToParseDatabaseImportFileException } from 'src/modules/database-import/exceptions'; import { CertificateImportService } from 'src/modules/database-import/certificate-import.service'; describe('DatabaseImportService', () => { let service: DatabaseImportService; + let certificateImportService: MockType; let databaseRepository: MockType; let analytics: MockType; let validatoSpy; @@ -52,6 +54,7 @@ describe('DatabaseImportService', () => { service = await module.get(DatabaseImportService); databaseRepository = await module.get(DatabaseRepository); + certificateImportService = await module.get(CertificateImportService); analytics = await module.get(DatabaseImportAnalytics); validatoSpy = jest.spyOn(service['validator'], 'validateOrReject'); }); @@ -61,6 +64,17 @@ describe('DatabaseImportService', () => { databaseRepository.create.mockRejectedValueOnce(new BadRequestException()); databaseRepository.create.mockRejectedValueOnce(new ForbiddenException()); validatoSpy.mockRejectedValueOnce([new ValidationError()]); + certificateImportService.processCaCertificate + .mockRejectedValueOnce(new InvalidCaCertificateBodyException()) + .mockRejectedValueOnce(new InvalidCaCertificateBodyException()) + .mockRejectedValueOnce(new InvalidCaCertificateBodyException()) + .mockRejectedValueOnce(new InvalidCaCertificateBodyException()) + .mockRejectedValueOnce(new InvalidCertificateNameException()); + certificateImportService.processClientCertificate + .mockRejectedValueOnce(new InvalidClientCertificateBodyException()) + .mockRejectedValueOnce(new InvalidClientCertificateBodyException()) + .mockRejectedValueOnce(new InvalidClientCertificateBodyException()) + .mockRejectedValueOnce(new InvalidCertificateNameException()); }); it('should import databases from json', async () => { diff --git a/redisinsight/api/src/modules/database-import/database-import.service.ts b/redisinsight/api/src/modules/database-import/database-import.service.ts index 6f3db4959a..78e0ac1df6 100644 --- a/redisinsight/api/src/modules/database-import/database-import.service.ts +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -297,11 +297,9 @@ export class DatabaseImportService { static parseFile(file): any { const data = file?.buffer?.toString(); - let databases; + let databases = DatabaseImportService.parseJson(data); - if (file?.mimetype === 'application/json') { - databases = DatabaseImportService.parseJson(data); - } else { + if (!databases) { databases = DatabaseImportService.parseBase64(data); } diff --git a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts index fa8c0d9a4d..d7c6f49fc1 100644 --- a/redisinsight/api/test/api/database-import/POST-databases-import.test.ts +++ b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts @@ -6,7 +6,9 @@ import { deps, requirements, validateApiCall, - getMainCheckFn, generateInvalidDataArray, + getMainCheckFn, + generateInvalidDataArray, + _, } from '../deps'; import { randomBytes } from 'crypto'; import { cloneDeep, set } from 'lodash'; @@ -40,23 +42,173 @@ const baseDatabaseData = { password: constants.TEST_REDIS_PASSWORD || '', } +const baseTls = { + tls: constants.TEST_REDIS_TLS_CA ? true : undefined, + caCert: constants.TEST_REDIS_TLS_CA ? { + name: constants.TEST_CA_NAME, + certificate: constants.TEST_REDIS_TLS_CA, + } : undefined, + clientCert: constants.TEST_USER_TLS_CERT ? { + name: constants.TEST_CLIENT_CERT_NAME, + certificate: constants.TEST_USER_TLS_CERT, + key: constants.TEST_USER_TLS_KEY, + } : undefined, +}; + const baseSentinelData = { - name: constants.TEST_SENTINEL_MASTER_GROUP, - username: constants.TEST_SENTINEL_MASTER_USER || null, - password: constants.TEST_SENTINEL_MASTER_PASS || null, + sentinelMaster: constants.TEST_RTE_TYPE === 'SENTINEL' ? { + name: constants.TEST_SENTINEL_MASTER_GROUP, + username: constants.TEST_SENTINEL_MASTER_USER || null, + password: constants.TEST_SENTINEL_MASTER_PASS || null, + } : undefined, + username: constants.TEST_SENTINEL_MASTER_USER ? constants.TEST_SENTINEL_MASTER_USER : constants.TEST_REDIS_USER, + password: constants.TEST_SENTINEL_MASTER_PASS ? constants.TEST_SENTINEL_MASTER_PASS : constants.TEST_REDIS_PASSWORD, } +const importDatabaseFormat0 = { + ...baseDatabaseData, + ...baseTls, + ...baseSentinelData, + connectionType: 'STANDALONE', + verifyServerCert: true, +}; + +const baseSentinelDataFormat1 = { + sentinelOptions: baseSentinelData.sentinelMaster ? { + sentinelPassword: baseSentinelData.password, + name: baseSentinelData.sentinelMaster.name, + } : undefined, + username: constants.TEST_SENTINEL_MASTER_USER ? constants.TEST_SENTINEL_MASTER_USER : constants.TEST_REDIS_USER, + password: constants.TEST_SENTINEL_MASTER_PASS ? constants.TEST_SENTINEL_MASTER_PASS : constants.TEST_REDIS_PASSWORD, +}; + const importDatabaseFormat1 = { + id: "1393c216-3fd0-4ad5-8412-209a8e8ec77c", name: baseDatabaseData.name, + type: 'standalone', + keyPrefix: null, + host: baseDatabaseData.host, + port: baseDatabaseData.port, + username: baseDatabaseData.username, + password: baseDatabaseData.password, + db: 0, + ssl: !!baseTls.tls, + caCert: baseTls.caCert ? constants.TEST_CA_CERT_PATH : null, + certificate: baseTls.clientCert ? constants.TEST_CLIENT_CERT_PATH : null, + keyFile: baseTls.clientCert ? constants.TEST_CLIENT_KEY_PATH : null, + ...baseSentinelDataFormat1, +} + + +const baseSentinelDataFormat2 = { + sentinelOptions: baseSentinelData.sentinelMaster ? { + masterName: baseSentinelData.sentinelMaster.name, + nodePassword: baseSentinelData.password, + } : undefined, + username: constants.TEST_SENTINEL_MASTER_USER ? constants.TEST_SENTINEL_MASTER_USER : constants.TEST_REDIS_USER, + auth: constants.TEST_SENTINEL_MASTER_PASS ? constants.TEST_SENTINEL_MASTER_PASS : constants.TEST_REDIS_PASSWORD, +}; + +const importDatabaseFormat2 = { host: baseDatabaseData.host, port: `${baseDatabaseData.port}`, + auth: baseDatabaseData.password, username: baseDatabaseData.username, + connectionName: baseDatabaseData.name, + cluster: false, + sslOptions: baseTls.caCert ? { + key: baseTls.clientCert ? constants.TEST_CLIENT_KEY_PATH : undefined, + cert: baseTls.clientCert ? constants.TEST_CLIENT_CERT_PATH : undefined, + ca: baseTls.caCert ? constants.TEST_CA_CERT_PATH : undefined, + } : undefined, + ...baseSentinelDataFormat2, +} + + +const baseSentinelDataFormat3 = { + username: constants.TEST_SENTINEL_MASTER_USER ? constants.TEST_SENTINEL_MASTER_USER : constants.TEST_REDIS_USER, + auth: constants.TEST_SENTINEL_MASTER_PASS ? constants.TEST_SENTINEL_MASTER_PASS : constants.TEST_REDIS_PASSWORD, +}; + +const importDatabaseFormat3 = { + name: baseDatabaseData.name, + host: baseDatabaseData.host, + port: baseDatabaseData.port, auth: baseDatabaseData.password, + username: baseDatabaseData.username, + ssl: !!baseTls.tls, + ssl_ca_cert_path: baseTls.caCert ? constants.TEST_CA_CERT_PATH : undefined, + ssl_local_cert_path: baseTls.clientCert ? constants.TEST_CLIENT_CERT_PATH : undefined, + ssl_private_key_path: baseTls.clientCert ? constants.TEST_CLIENT_KEY_PATH : undefined, + ...baseSentinelDataFormat3, } const mainCheckFn = getMainCheckFn(endpoint); +const checkConnection = async (databaseId: string, statusCode = 200) => { + await validateApiCall({ + endpoint: () => request(server).get(`/${constants.API.DATABASES}/${databaseId}/connect`), + statusCode, + }); +}; + +const checkDataManagement = async (databaseId: string) => { + await validateApiCall({ + endpoint: () => request(server).post(`/${constants.API.DATABASES}/${databaseId}/workbench/command-executions`), + data: { + commands: ['set string value'], + }, + checkFn: ({ body }) => { + expect(body[0].result).to.deep.eq([{ + status: 'success', + response: 'OK', + }]) + } + }); +}; + +const validateImportedDatabase = async ( + name: string, + initType: string, + detectedType: string, + dataCheck = true, +) => { + let database = await localDb.getInstanceByName(name); + expect(database.connectionType).to.eq(initType); + expect(database.new).to.eq(true); + + await checkConnection(database.id); + database = await localDb.getInstanceByName(name); + + expect(database.connectionType).to.eq(detectedType); + expect(database.new).to.eq(false); + + if (dataCheck) { + await checkDataManagement(database.id) + } +}; + +const validatePartialImportedDatabase = async ( + name: string, + initType: string, + detectedType: string, + statusCode = 400, +) => { + let database = await localDb.getInstanceByName(name); + expect(database.connectionType).to.eq(initType); + expect(database.new).to.eq(true); + + await checkConnection(database.id, statusCode); + database = await localDb.getInstanceByName(name); + + expect(database.connectionType).to.eq(detectedType); + expect(database.new).to.eq(true); +}; + +let name; + describe('POST /databases/import', () => { + beforeEach(() => { name = constants.getRandomString(); }) describe('Validation', function () { generateInvalidDataArray(databaseSchema) .map(({ path, value }) => { @@ -147,13 +299,296 @@ describe('POST /databases/import', () => { }, ].map(mainCheckFn); }); + describe('Certificates', () => { + describe('CA', () => { + it('Should create only 1 certificate', async () => { + const caCertName = constants.getRandomString(); + + const caCerts = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify((new Array(10).fill(1)).map((c, idx) => { + return { + ...baseDatabaseData, + tls: true, + caCert: { + name: caCertName, + certificate: `-----BEGIN CERTIFICATE-----caCert`, + }, + name, + } + }))), 'file.json'], + responseBody: { + total: 10, + success: (new Array(10).fill(1)).map((_v, index) => ({ + index, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + })), + partial: [], + fail: [], + }, + }); + + const caCerts2 = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + const diff = _.differenceWith(caCerts2, caCerts, _.isEqual); + + expect(diff.length).to.eq(1); + expect(diff[0].name).to.eq(caCertName); + + // import more + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify((new Array(10).fill(1)).map((c, idx) => { + return { + ...baseDatabaseData, + tls: true, + caCert: { + name: caCertName, + certificate: `-----BEGIN CERTIFICATE-----caCert`, + }, + name, + } + }))), 'file.json'], + responseBody: { + total: 10, + success: (new Array(10).fill(1)).map((_v, index) => ({ + index, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + })), + partial: [], + fail: [], + }, + }); + + const caCerts3 = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + const diff2 = _.differenceWith(caCerts3, caCerts2, _.isEqual); + + expect(diff2.length).to.eq(0); + }); + it('Should create multiple certs with name prefixes', async () => { + const caCertName = constants.getRandomString(); + + const caCerts = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify((new Array(10).fill(1)).map((c, idx) => { + return { + ...baseDatabaseData, + tls: true, + caCert: { + name: caCertName, + certificate: `-----BEGIN CERTIFICATE-----caCert_${idx}`, + }, + name, + } + }))), 'file.json'], + responseBody: { + total: 10, + success: (new Array(10).fill(1)).map((_v, index) => ({ + index, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + })), + partial: [], + fail: [], + }, + }); + + const caCerts2 = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + const diff = _.differenceWith(caCerts2, caCerts, _.isEqual); + + expect(diff.length).to.eq(10); + expect(diff[0].name).to.eq(caCertName); + expect(diff[1].name).to.eq(`1_${caCertName}`); + expect(diff[2].name).to.eq(`2_${caCertName}`); + expect(diff[3].name).to.eq(`3_${caCertName}`); + expect(diff[9].name).to.eq(`9_${caCertName}`); + }); + }); + describe('CLIENT', () => { + it('Should create only 1 certificate', async () => { + const caCertName = constants.getRandomString(); + const clientCertName = constants.getRandomString(); + + const caCerts = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + const clientCerts = await (await localDb.getRepository(localDb.repositories.CLIENT_CERT_REPOSITORY)).find(); + + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify((new Array(10).fill(1)).map((c, idx) => { + return { + ...baseDatabaseData, + tls: true, + caCert: { + name: caCertName, + certificate: `-----BEGIN CERTIFICATE-----caCert__`, + }, + clientCert: { + name: clientCertName, + certificate: `-----BEGIN CERTIFICATE-----clientCert__`, + key: `-----BEGIN PRIVATE KEY-----clientKey__`, + }, + name, + } + }))), 'file.json'], + responseBody: { + total: 10, + success: (new Array(10).fill(1)).map((_v, index) => ({ + index, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + })), + partial: [], + fail: [], + }, + }); + + const caCerts2 = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + const diff = _.differenceWith(caCerts2, caCerts, _.isEqual); + expect(diff.length).to.eq(1); + expect(diff[0].name).to.eq(caCertName); + + const clientCerts2 = await (await localDb.getRepository(localDb.repositories.CLIENT_CERT_REPOSITORY)).find(); + const clientDiff = _.differenceWith(clientCerts2, clientCerts, _.isEqual); + expect(clientDiff.length).to.eq(1); + expect(clientDiff[0].name).to.eq(clientCertName); + + + // import more + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify((new Array(10).fill(1)).map((c, idx) => { + return { + ...baseDatabaseData, + tls: true, + caCert: { + name: caCertName, + certificate: `-----BEGIN CERTIFICATE-----caCert__`, + }, + clientCert: { + name: clientCertName, + certificate: `-----BEGIN CERTIFICATE-----clientCert__`, + key: `-----BEGIN PRIVATE KEY-----clientKey__`, + }, + name, + } + }))), 'file.json'], + responseBody: { + total: 10, + success: (new Array(10).fill(1)).map((_v, index) => ({ + index, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + })), + partial: [], + fail: [], + }, + }); + + const caCerts3 = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + const diff2 = _.differenceWith(caCerts3, caCerts2, _.isEqual); + expect(diff2.length).to.eq(0); + + const clientCerts3 = await (await localDb.getRepository(localDb.repositories.CLIENT_CERT_REPOSITORY)).find(); + const clientDiff2 = _.differenceWith(clientCerts3, clientCerts2, _.isEqual); + expect(clientDiff2.length).to.eq(0); + }); + it('Should create multiple certs with name prefixes', async () => { + const caCertName = constants.getRandomString(); + const clientCertName = constants.getRandomString(); + + const caCerts = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + const clientCerts = await (await localDb.getRepository(localDb.repositories.CLIENT_CERT_REPOSITORY)).find(); + + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify((new Array(10).fill(1)).map((c, idx) => { + return { + ...baseDatabaseData, + tls: true, + caCert: { + name: caCertName, + certificate: `-----BEGIN CERTIFICATE-----caCert__${idx}`, + }, + clientCert: { + name: clientCertName, + certificate: `-----BEGIN CERTIFICATE-----clientCert__${idx}`, + key: `-----BEGIN PRIVATE KEY-----clientKey__${idx}`, + }, + name, + } + }))), 'file.json'], + responseBody: { + total: 10, + success: (new Array(10).fill(1)).map((_v, index) => ({ + index, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + })), + partial: [], + fail: [], + }, + }); + + const caCerts2 = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)).find(); + const diff = _.differenceWith(caCerts2, caCerts, _.isEqual); + expect(diff.length).to.eq(10); + expect(diff[0].name).to.eq(caCertName); + expect(diff[1].name).to.eq(`1_${caCertName}`); + expect(diff[2].name).to.eq(`2_${caCertName}`); + expect(diff[3].name).to.eq(`3_${caCertName}`); + expect(diff[9].name).to.eq(`9_${caCertName}`); + + const clientCerts2 = await (await localDb.getRepository(localDb.repositories.CLIENT_CERT_REPOSITORY)).find(); + const clientDiff = _.differenceWith(clientCerts2, clientCerts, _.isEqual); + expect(clientDiff.length).to.eq(10); + expect(clientDiff[0].name).to.eq(clientCertName); + expect(clientDiff[1].name).to.eq(`1_${clientCertName}`); + expect(clientDiff[2].name).to.eq(`2_${clientCertName}`); + expect(clientDiff[3].name).to.eq(`3_${clientCertName}`); + expect(clientDiff[9].name).to.eq(`9_${clientCertName}`); + }); + }); + }); describe('STANDALONE', () => { requirements('rte.type=STANDALONE'); describe('NO TLS', function () { requirements('!rte.tls'); - it('Import standalone without tls (format 1)', async () => { - const name = constants.getRandomString(); + it('Import standalone (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + }], + partial: [], + fail: [], + }, + }); + await validateImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone (format 1)', async () => { await validateApiCall({ endpoint, attach: ['file', Buffer.from(JSON.stringify([ @@ -168,24 +603,68 @@ describe('POST /databases/import', () => { index: 0, status: 'success', host: importDatabaseFormat1.host, - port: parseInt(importDatabaseFormat1.port, 10), + port: importDatabaseFormat1.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone (format 2)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat2, + name, + } + ])).toString('base64')), 'file.ano'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat2.host, + port: parseInt(importDatabaseFormat2.port, 10), }], partial: [], fail: [], }, }); - // check connection - const database = await localDb.getInstanceByName(name); + await validateImportedDatabase(name, 'NOT CONNECTED', 'STANDALONE'); + }); + it('Import standalone (format 3)', async () => { + const name = constants.getRandomString(); + await validateApiCall({ - endpoint: () => request(server).get(`/${constants.API.DATABASES}/${database.id}/connect`), - statusCode: 200, + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat3, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat3.host, + port: importDatabaseFormat3.port, + }], + partial: [], + fail: [], + }, }); - expect(database.new).to.eq(true); + + await validateImportedDatabase(name, 'NOT CONNECTED', 'STANDALONE'); }); describe('Oss', () => { requirements('!rte.re'); - it('Import standalone with particular db index', async () => { + it('Import standalone with particular db index (format 1)', async () => { const name = constants.getRandomString(); const cliUuid = constants.getRandomString(); const browserKeyName = constants.getRandomString(); @@ -195,7 +674,7 @@ describe('POST /databases/import', () => { endpoint, attach: ['file', Buffer.from(JSON.stringify([ { - ...importDatabaseFormat1, + ...importDatabaseFormat0, name, db: constants.TEST_REDIS_DB_INDEX, } @@ -205,8 +684,8 @@ describe('POST /databases/import', () => { success: [{ index: 0, status: 'success', - host: importDatabaseFormat1.host, - port: parseInt(importDatabaseFormat1.port, 10), + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, }], partial: [], fail: [], @@ -258,63 +737,868 @@ describe('POST /databases/import', () => { }); }); }); - xdescribe('TLS CA', function () { + describe('TLS CA', function () { requirements('rte.tls', '!rte.tlsAuth'); - }); - xdescribe('TLS AUTH', function () { - requirements('rte.tls', 'rte.tlsAuth'); - }); - }); - describe('CLUSTER', () => { - requirements('rte.type=CLUSTER'); - describe('NO TLS', function () { - requirements('!rte.tls'); - it('should import cluster database (base64)', async () => { - const name = constants.getRandomString(); - + it('Import standalone with CA tls (format 0)', async () => { await validateApiCall({ endpoint, - attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + attach: ['file', Buffer.from(JSON.stringify([ { - ...importDatabaseFormat1, + ...importDatabaseFormat0, name, - cluster: true, } - ])).toString('base64')), 'file.ano'], + ])), 'file.json'], responseBody: { total: 1, success: [{ index: 0, status: 'success', - host: importDatabaseFormat1.host, - port: parseInt(importDatabaseFormat1.port, 10), + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, }], partial: [], fail: [], }, }); + await validateImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone with CA tls partial with wrong body (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + caCert: { + ...importDatabaseFormat0.caCert, + certificate: 'bad body', + }, + name, + } + ])), 'file'], + responseBody: { + total: 1, + success: [], + partial: [{ + index: 0, + status: 'partial', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + errors: [ + { + message: 'Invalid CA body', + statusCode: 400, + error: 'Invalid Ca Certificate Body', + } + ], + }], + fail: [], + }, + }); - // check connection - const database = await localDb.getInstanceByName(name); - - expect(database.new).to.eq(true); - expect(database.nodes).to.eq('[]'); - expect(database.connectionType).to.eq('CLUSTER'); + await validatePartialImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone with CA tls partial with no ca name (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + caCert: { + ...importDatabaseFormat0.caCert, + name: undefined, + }, + name, + } + ])), 'file'], + responseBody: { + total: 1, + success: [], + partial: [{ + index: 0, + status: 'partial', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + errors: [ + { + message: 'Certificate name is not defined', + statusCode: 400, + error: 'Invalid Certificate Name', + }, + ], + }], + fail: [], + }, + }); + await validatePartialImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone with CA tls (format 1)', async () => { await validateApiCall({ - endpoint: () => request(server).get(`/${constants.API.DATABASES}/${database.id}/connect`), - statusCode: 200, + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat1.host, + port: importDatabaseFormat1.port, + }], + partial: [], + fail: [], + }, }); - expect((await localDb.getInstanceByName(name)).nodes).to.not.eq('[]'); + await validateImportedDatabase(name, 'STANDALONE', 'STANDALONE'); }); - }); - xdescribe('TLS CA', function () { - requirements('rte.tls', '!rte.tlsAuth'); - }); - }); - xdescribe('SENTINEL', () => { - requirements('rte.type=SENTINEL'); + it('Import standalone with CA tls partial with no ca file (format 1)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + caCert: 'not-existing-path', + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [], + partial: [{ + index: 0, + status: 'partial', + host: importDatabaseFormat1.host, + port: importDatabaseFormat1.port, + errors: [ + { + message: 'Invalid CA body', + statusCode: 400, + error: 'Invalid Ca Certificate Body', + } + ], + }], + fail: [], + }, + }); + + await validatePartialImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone with CA tls (format 2)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat2, + name, + } + ])).toString('base64')), 'file.ano'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat2.host, + port: parseInt(importDatabaseFormat2.port, 10), + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'NOT CONNECTED', 'STANDALONE'); + }); + it('Import standalone with CA tls (format 3)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat3, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat3.host, + port: importDatabaseFormat3.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'NOT CONNECTED', 'STANDALONE'); + }); + + }); + describe('TLS AUTH', function () { + requirements('rte.tls', 'rte.tlsAuth'); + it('Import standalone with CA + CLIENT tls (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone with CA + CLIENT tls partial with wrong bodies (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + caCert: { + ...importDatabaseFormat0.caCert, + certificate: 'bad body', + }, + clientCert: { + ...importDatabaseFormat0.clientCert, + certificate: 'bad body', + }, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [], + partial: [{ + index: 0, + status: 'partial', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + errors: [ + { + message: 'Invalid CA body', + statusCode: 400, + error: 'Invalid Ca Certificate Body' + }, + { + message: 'Invalid certificate body', + statusCode: 400, + error: 'Invalid Client Certificate Body' + } + ], + }], + fail: [], + }, + }); + + await validatePartialImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone with CA + CLIENT tls partial with no cert name (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + caCert: { + ...importDatabaseFormat0.caCert, + certificate: 'bad body', + }, + clientCert: { + ...importDatabaseFormat0.clientCert, + name: undefined, + }, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [], + partial: [{ + index: 0, + status: 'partial', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + errors: [ + { + message: 'Invalid CA body', + statusCode: 400, + error: 'Invalid Ca Certificate Body' + }, + { + message: 'Certificate name is not defined', + statusCode: 400, + error: 'Invalid Certificate Name', + }, + ], + }], + fail: [], + }, + }); + + await validatePartialImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone with CA + CLIENT tls (format 1)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat1.host, + port: importDatabaseFormat1.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone with CA + CLIENT tls partial with wrong key (format 1)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + clientCert: { + ...importDatabaseFormat0.clientCert, + key: 'bad path', + }, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [], + partial: [{ + index: 0, + status: 'partial', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + errors: [ + { + message: 'Invalid private key', + statusCode: 400, + error: 'Invalid Client Private Key', + }, + ], + }], + fail: [], + }, + }); + + await validatePartialImportedDatabase(name, 'STANDALONE', 'STANDALONE'); + }); + it('Import standalone with CA + CLIENT tls (format 2)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat2, + name, + } + ])).toString('base64')), 'file.ano'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat2.host, + port: parseInt(importDatabaseFormat2.port, 10), + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'NOT CONNECTED', 'STANDALONE'); + }); + it('Import standalone with CA + CLIENT tls (format 3)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat3, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat3.host, + port: importDatabaseFormat3.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'NOT CONNECTED', 'STANDALONE'); + }); + }); + }); + describe('CLUSTER', () => { + requirements('rte.type=CLUSTER'); + describe('NO TLS', function () { + requirements('!rte.tls'); + it('Import cluster (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + connectionType: 'CLUSTER', + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'CLUSTER', 'CLUSTER'); + }); + it('Import cluster (format 1)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + type: 'cluster', + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat1.host, + port: importDatabaseFormat1.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'CLUSTER', 'CLUSTER'); + }); + it('Import cluster (format 2)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat2, + name, + cluster: true, + } + ])).toString('base64')), 'file.ano'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat2.host, + port: parseInt(importDatabaseFormat2.port, 10), + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'CLUSTER', 'CLUSTER'); + }); + it('Import cluster auto discovered (format 2)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat2, + name, + cluster: false, + } + ])).toString('base64')), 'file.ano'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat2.host, + port: parseInt(importDatabaseFormat2.port, 10), + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'NOT CONNECTED', 'CLUSTER'); + }); + it('Import cluster (format 3)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat3, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat3.host, + port: importDatabaseFormat3.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'NOT CONNECTED', 'CLUSTER'); + }); + }); + describe('TLS CA', function () { + requirements('rte.tls', '!rte.tlsAuth'); + it('Import cluster with CA tls (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + connectionType: 'CLUSTER', + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'CLUSTER', 'CLUSTER'); + }); + it('Import cluster with CA tls (format 1)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + type: 'cluster', + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat1.host, + port: importDatabaseFormat1.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'CLUSTER', 'CLUSTER'); + }); + it('Import cluster with CA tls (format 2)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat2, + name, + cluster: true, + } + ])).toString('base64')), 'file.ano'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat2.host, + port: parseInt(importDatabaseFormat2.port, 10), + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'CLUSTER', 'CLUSTER'); + }); + it('Import cluster with CA tls (format 3)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat3, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat3.host, + port: importDatabaseFormat3.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'NOT CONNECTED', 'CLUSTER'); + }); + }); + }); + describe('SENTINEL', () => { + requirements('rte.type=SENTINEL'); + describe('NO TLS', function () { + requirements('!rte.tls'); + it('Import sentinel (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + connectionType: 'SENTINEL', + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'SENTINEL', 'SENTINEL'); + }); + it('Import sentinel (format 1)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + type: 'sentinel', + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat1.host, + port: importDatabaseFormat1.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'SENTINEL', 'SENTINEL'); + }); + it('Import sentinel (format 2)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat2, + name, + } + ])).toString('base64')), 'file.ano'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat2.host, + port: parseInt(importDatabaseFormat2.port, 10), + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'SENTINEL', 'SENTINEL'); + }); + it('Import sentinel (format 3)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat3, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat3.host, + port: importDatabaseFormat3.port, + }], + partial: [], + fail: [], + }, + }); + + // should determine connection type as standalone since we don't have sentinel auto discovery + await validateImportedDatabase(name, 'NOT CONNECTED', 'STANDALONE', false); + }); + }); + describe('TLS AUTH', function () { + requirements('rte.tls', 'rte.tlsAuth'); + it('Import sentinel with CA + CLIENT tls (format 0)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat0, + connectionType: 'SENTINEL', + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'SENTINEL', 'SENTINEL'); + }); + it('Import sentinel with CA + CLIENT tls (format 1)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat1, + type: 'sentinel', + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat1.host, + port: importDatabaseFormat1.port, + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'SENTINEL', 'SENTINEL'); + }); + it('Import sentinel with CA + CLIENT tls (format 2)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat2, + name, + } + ])).toString('base64')), 'file.ano'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat2.host, + port: parseInt(importDatabaseFormat2.port, 10), + }], + partial: [], + fail: [], + }, + }); + + await validateImportedDatabase(name, 'SENTINEL', 'SENTINEL'); + }); + it('Import sentinel with CA + CLIENT tls (format 3)', async () => { + await validateApiCall({ + endpoint, + attach: ['file', Buffer.from(JSON.stringify([ + { + ...importDatabaseFormat3, + name, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat3.host, + port: importDatabaseFormat3.port, + }], + partial: [], + fail: [], + }, + }); + + // should determine connection type as standalone since we don't have sentinel auto discovery + await validateImportedDatabase(name, 'NOT CONNECTED', 'STANDALONE', false); + }); + }); }); }); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index cf3b0b64c9..5f43ac0df7 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -1,4 +1,5 @@ import { v4 as uuidv4 } from 'uuid'; +import * as path from 'path'; import { randomBytes } from 'crypto'; import { getASCIISafeStringFromBuffer, getBufferFromSafeASCIIString } from "src/utils/cli-helper"; @@ -21,6 +22,8 @@ const unprintableBuf = Buffer.concat([ Buffer.from(CLUSTER_HASH_SLOT), ]); +const CERTS_FOLDER = process.env.CERTS_FOLDER || './coverage'; + export const constants = { // api API, @@ -112,6 +115,10 @@ export const constants = { TEST_CA_NAME: 'ca certificate', TEST_CA_FILENAME: 'redisCA.crt', TEST_CA_CERT: '-----BEGIN CERTIFICATE-----\nMIIFazCCA1OgAwIBAgIUavmmz7/4r2muhE1He1u/6S1jLXEwDQYJKoZIhvcNAQEL\nBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\nGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMTA2MjIxNjMwNTJaFw00ODEx\nMDcxNjMwNTJaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\nHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB\nAQUAA4ICDwAwggIKAoICAQDGUx5tjluzcotvXlr4XCqAbdO+ehD/djqzb3KxB4p4\nUNE5tqS4TykvbisASOnNj/643J0BSYKEZwNBjy/oAWmc3cVMq30WnWRENGRUKyr+\nqhgjR0OGMHxpAU8DiDgsJAuvvh86SU0xIo6PUWyO38XNIOGt05s61My9fW+Allai\n5/jj6knBej42cRY7B9hUgHfko9NXE5oUVFKE+dpH9IiMUGBm7SDi1ysB1vIMQhcT\n8ugQHdwXAiQfhDODNuDG48z6OprhGgHN5lYNFd3oFlweoFaqE0psFRh9bR5AuqES\nubxEFqMVwEjyJa8BgObRBwdHoipZt1FLDeKTP5/MGUm5n/2X+pcAi4Q7+9i+aVz5\ngFiCz6ndOFEj3X4CXcHHLVzI8ukQ3wQiDFXnomLOcFcuAJ9t+MisUOwts/Nvmqa0\n+copNgXu2N8K01G77HX1qbJ0uyF6pupw2EWW0yJXkoSeOeaFegHPMx6y3RUx1adl\nKu9vQ8JDodK4OwHfQcSBgj8aKA7huBnclgpBmM6B1czC6pw7DN6orLOlsx6cUusP\n4mELM2CNNYLUQuxhghTO8lAQTgvvth5MNSpxA6x/gKFGmLN9XUJIZweQQymeY137\n8elXS2yuoSyppisB+HDvp6MbegN1ldzhI0AjdUj9NDiiO5sDk+XscKA8tsZz/MgW\nMQIDAQABo1MwUTAdBgNVHQ4EFgQU0CzAfHYx+Tr/axoAsurYNR/t2RMwHwYDVR0j\nBBgwFoAU0CzAfHYx+Tr/axoAsurYNR/t2RMwDwYDVR0TAQH/BAUwAwEB/zANBgkq\nhkiG9w0BAQsFAAOCAgEAd6Fqt+Ji1DV/7XA6e5QeCjqhrPxsXaUorbNSy2a4U59y\nRj5lmI8RUPBt6AtSLWpeZ5JU2NQpK+4YfbopSPnVtc8Xipta1VmSr2grjT0n4cjY\nXkMHV4bwaHBhr1OI2REcBOiwNP2QzXK7uFa75nZUyQSC0C3Qi5EJri2+a6xMsuF5\nE8a9eyIvst1ESXJ9IJITc8e/eYFtpGw7WRClcm1UblwqYpO9sW9fFuZDpuBC0UH1\nGXolRnFYN8PstjxmXHtrjHGcmOY+t1yFnyxOgZ01rmaFt+JEFbPOmgN17wcAidrV\nAuXKWal9zrtlJc1J8GPHPpBTlZ+Qq5TlPI7Z3Boj9FCZdl3JEWUZGP7TPjxCWLoH\n2/wJppE7w2bQcnidQngZhf2PN5RNQASUa2QBae7rkztReJ6A/xMWXAOfgkj13IbS\nPIDZnBQYp5DKAxL9PRB/javL57/fUtYAxxzZK4xbvwY/lygv3+NetPqRHnx/IVBj\nuEal2rpdwyFcoJ3DODbh9eh6tWJB4wR8QyYm3ATF1VV+x6XX5u5t5Z4IUt8WJkgn\nHGzepJVYxzJMzjlyjqF1IG9e1da8c4DdRgmOn3R55G5BWQR3i6J+RAQY/O1S3VKA\n0FDYT/EDZRbtXWwStSWUIPxNZt62vNGgwzprQow9OfJHRuOzlzIiK2BqnixboOs=\n-----END CERTIFICATE-----\n', + CERTS_FOLDER, + TEST_CA_CERT_PATH: path.join(CERTS_FOLDER, 'ca.crt'), + TEST_CLIENT_CERT_PATH: path.join(CERTS_FOLDER, 'client.crt'), + TEST_CLIENT_KEY_PATH: path.join(CERTS_FOLDER, 'client.key'), // Redis Strings TEST_STRING_TYPE: 'string', diff --git a/redisinsight/api/test/helpers/redis.ts b/redisinsight/api/test/helpers/redis.ts index 076481ee24..4ded8914e1 100644 --- a/redisinsight/api/test/helpers/redis.ts +++ b/redisinsight/api/test/helpers/redis.ts @@ -1,6 +1,7 @@ import * as Redis from 'ioredis'; import * as IORedis from 'ioredis'; import * as semverCompare from 'node-version-compare'; +import * as fs from 'fs'; import { constants } from './constants'; import { parseReplToObject, parseClusterNodesResponse } from './utils'; import { initDataHelper } from './data/redis'; @@ -202,6 +203,15 @@ export const initRTE = async () => { rte.data = await initDataHelper(rte); + // generate cert files + if (rte.env.tls) { + fs.writeFileSync(constants.TEST_CA_CERT_PATH, constants.TEST_REDIS_TLS_CA); + } + if (rte.env.tlsAuth) { + fs.writeFileSync(constants.TEST_CLIENT_CERT_PATH, constants.TEST_USER_TLS_CERT); + fs.writeFileSync(constants.TEST_CLIENT_KEY_PATH, constants.TEST_USER_TLS_KEY); + } + return rte; }; diff --git a/redisinsight/api/test/test-runs/docker.build.env b/redisinsight/api/test/test-runs/docker.build.env index a4d48a9ba8..806b310d67 100644 --- a/redisinsight/api/test/test-runs/docker.build.env +++ b/redisinsight/api/test/test-runs/docker.build.env @@ -4,3 +4,4 @@ RTE=defaultrte APP_IMAGE=riv2:latest TEST_BE_SERVER=https://app:5000/api NOTIFICATION_UPDATE_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/notifications.json +CERTS_FOLDER=/root/.redisinsight-v2.0 diff --git a/redisinsight/api/test/test-runs/docker.build.yml b/redisinsight/api/test/test-runs/docker.build.yml index 85aa785340..fbde408d96 100644 --- a/redisinsight/api/test/test-runs/docker.build.yml +++ b/redisinsight/api/test/test-runs/docker.build.yml @@ -20,6 +20,7 @@ services: - redis - app environment: + CERTS_FOLDER: "/root/.redisinsight-v2.0" TEST_REDIS_HOST: "redis" DB_SYNC: "true" TEST_BE_SERVER: ${TEST_BE_SERVER} @@ -34,6 +35,7 @@ services: volumes: - ${COV_FOLDER}:/root/.redisinsight-v2.0 environment: + CERTS_FOLDER: "/root/.redisinsight-v2.0" DB_SYNC: "true" DB_MIGRATIONS: "false" APP_FOLDER_NAME: ".redisinsight-v2.0" diff --git a/redisinsight/api/test/test-runs/local.build.env b/redisinsight/api/test/test-runs/local.build.env index d38d8d84b4..7b32446487 100644 --- a/redisinsight/api/test/test-runs/local.build.env +++ b/redisinsight/api/test/test-runs/local.build.env @@ -2,3 +2,4 @@ COV_FOLDER=./coverage ID=defaultid RTE=defaultrte NOTIFICATION_UPDATE_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/notifications.json +CERTS_FOLDER=/root/.redisinsight-v2.0 diff --git a/redisinsight/api/test/test-runs/local.build.yml b/redisinsight/api/test/test-runs/local.build.yml index a3f47c446b..e8e04c9173 100644 --- a/redisinsight/api/test/test-runs/local.build.yml +++ b/redisinsight/api/test/test-runs/local.build.yml @@ -14,9 +14,11 @@ services: tty: true volumes: - ${COV_FOLDER}:/usr/src/app/coverage + - ${COV_FOLDER}:/root/.redisinsight-v2.0 depends_on: - redis environment: + CERTS_FOLDER: "/root/.redisinsight-v2.0" TEST_REDIS_HOST: "redis" NOTIFICATION_UPDATE_URL: "https://s3.amazonaws.com/redisinsight.test/public/tests/notifications.json" From 565c50168a54e1656e1a3802581007825621ed3d Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 19 Dec 2022 09:27:27 +0200 Subject: [PATCH 50/57] fix failed test --- redisinsight/api/test/api/database/GET-databases.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisinsight/api/test/api/database/GET-databases.test.ts b/redisinsight/api/test/api/database/GET-databases.test.ts index 41cfba07ee..c128a6987a 100644 --- a/redisinsight/api/test/api/database/GET-databases.test.ts +++ b/redisinsight/api/test/api/database/GET-databases.test.ts @@ -12,7 +12,7 @@ const responseSchema = Joi.array().items(Joi.object().keys({ db: Joi.number().integer().allow(null).required(), name: Joi.string().required(), new: Joi.boolean().allow(null).required(), - connectionType: Joi.string().valid('STANDALONE', 'SENTINEL', 'CLUSTER').required(), + connectionType: Joi.string().valid('STANDALONE', 'SENTINEL', 'CLUSTER', 'NOT CONNECTED').required(), lastConnection: Joi.string().isoDate().allow(null).required(), modules: Joi.array().items(Joi.object().keys({ name: Joi.string().required(), From 09e98fee92bb9ecc9b8552ec1af7e174704222f2 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 19 Dec 2022 16:47:18 +0100 Subject: [PATCH 51/57] updates for test-data folder structure --- .../{ => import-databases}/racompass-invalid.json | 0 .../{ => import-databases}/racompass-valid.json | 0 .../test-data/{ => import-databases}/rdm-full.json | 0 .../critical-path/database/import-databases.e2e.ts | 11 ++++++----- 4 files changed, 6 insertions(+), 5 deletions(-) rename tests/e2e/test-data/{ => import-databases}/racompass-invalid.json (100%) rename tests/e2e/test-data/{ => import-databases}/racompass-valid.json (100%) rename tests/e2e/test-data/{ => import-databases}/rdm-full.json (100%) diff --git a/tests/e2e/test-data/racompass-invalid.json b/tests/e2e/test-data/import-databases/racompass-invalid.json similarity index 100% rename from tests/e2e/test-data/racompass-invalid.json rename to tests/e2e/test-data/import-databases/racompass-invalid.json diff --git a/tests/e2e/test-data/racompass-valid.json b/tests/e2e/test-data/import-databases/racompass-valid.json similarity index 100% rename from tests/e2e/test-data/racompass-valid.json rename to tests/e2e/test-data/import-databases/racompass-valid.json diff --git a/tests/e2e/test-data/rdm-full.json b/tests/e2e/test-data/import-databases/rdm-full.json similarity index 100% rename from tests/e2e/test-data/rdm-full.json rename to tests/e2e/test-data/import-databases/rdm-full.json diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index 79812dd615..039eb1ca37 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -16,14 +16,14 @@ const racompassValidJson = 'racompass-valid.json'; const racompassInvalidJson = 'racompass-invalid.json'; const rdmFullJson = 'rdm-full.json'; const ardmValidAno = 'ardm-valid.ano'; -const listOfDB = JSON.parse(fs.readFileSync(path.join('test-data', 'rdm-full.json'), 'utf-8')); +const listOfDB = JSON.parse(fs.readFileSync(path.join('test-data', 'import-databases', 'rdm-full.json'), 'utf-8')); const dbSuccessNames = listOfDB.filter(element => element.result === 'success').map(item => item.name); const dbPartialNames = listOfDB.filter(element => element.result === 'partial').map(item => item.name); const dbFailedNames = listOfDB.filter(element => element.result === 'failed').map(item => item.name); const dbImportedNames = [...dbSuccessNames, ...dbPartialNames]; const rdmData = { type: 'rdm', - path: path.join('..', '..', '..', 'test-data', rdmFullJson), + path: path.join('..', '..', '..', 'test-data', 'import-databases', rdmFullJson), connectionType: 'Cluster', successNumber: dbSuccessNames.length, partialNumber: dbPartialNames.length, @@ -32,15 +32,16 @@ const rdmData = { const dbData = [ { type: 'racompass', - path: path.join('..', '..', '..', 'test-data', racompassValidJson), + path: path.join('..', '..', '..', 'test-data', 'import-databases', racompassValidJson), dbNames: ['racompassCluster', 'racompassDbWithIndex:8100 [1]'] }, { type: 'ardm', - path: path.join('..', '..', '..', 'test-data', ardmValidAno), + path: path.join('..', '..', '..', 'test-data', 'import-databases', ardmValidAno), dbNames: ['ardmNoName:12001', 'ardmWithPassAndUsername'] } ]; +const racompassInvalidJsonPath = path.join('..', '..', '..', 'test-data', 'import-databases', racompassInvalidJson); // List of all created databases to delete const databases = [ dbData[0].dbNames[0], @@ -79,7 +80,7 @@ test('Connection import from JSON', async t => { // Verify that user see the message when parse error appears await t - .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [path.join('..', '..', '..', 'test-data', racompassInvalidJson)]) + .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [racompassInvalidJsonPath]) .click(myRedisDatabasePage.submitImportBtn) .expect(myRedisDatabasePage.failedImportMessage.exists).ok('Failed to add database message not displayed') .expect(myRedisDatabasePage.failedImportMessage.textContent).contains(parseFailedMsg) From 33a555340ec7039d2568b6ddbe5dca308d07cfd0 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 20 Dec 2022 09:41:44 +0100 Subject: [PATCH 52/57] added tests for certificates --- .../pageObjects/add-redis-database-page.ts | 11 +++-- .../pageObjects/my-redis-databases-page.ts | 49 +++++++++++++++++++ .../{ => import-databases}/ardm-valid.ano | 0 .../database/import-databases.e2e.ts | 49 ++++++++++++++----- 4 files changed, 93 insertions(+), 16 deletions(-) rename tests/e2e/test-data/{ => import-databases}/ardm-valid.ano (100%) diff --git a/tests/e2e/pageObjects/add-redis-database-page.ts b/tests/e2e/pageObjects/add-redis-database-page.ts index 22fcad871b..92a6f68350 100644 --- a/tests/e2e/pageObjects/add-redis-database-page.ts +++ b/tests/e2e/pageObjects/add-redis-database-page.ts @@ -48,6 +48,9 @@ export class AddRedisDatabasePage { buildFromSource = Selector('a').withExactText('Build from source'); buildFromDocker = Selector('a').withExactText('Docker'); buildFromHomebrew = Selector('a').withExactText('Homebrew'); + // DROPDOWNS + caCertField = Selector('[data-testid=select-ca-cert]', {timeout: 500}); + clientCertField = Selector('[data-testid=select-cert]', {timeout: 500}); /** * Adding a new redis database @@ -61,7 +64,7 @@ export class AddRedisDatabasePage { await t .typeText(this.hostInput, parameters.host, { replace: true, paste: true }) .typeText(this.portInput, parameters.port, { replace: true, paste: true }) - .typeText(this.databaseAliasInput, parameters.databaseName, { replace: true, paste: true }); + .typeText(this.databaseAliasInput, parameters.databaseName!, { replace: true, paste: true }); if (!!parameters.databaseUsername) { await t.typeText(this.usernameInput, parameters.databaseUsername, { replace: true, paste: true }); } @@ -82,7 +85,7 @@ export class AddRedisDatabasePage { await t .typeText(this.hostInput, parameters.host, { replace: true, paste: true }) .typeText(this.portInput, parameters.port, { replace: true, paste: true }) - .typeText(this.databaseAliasInput, parameters.databaseName, { replace: true, paste: true }); + .typeText(this.databaseAliasInput, parameters.databaseName!, { replace: true, paste: true }); if (!!parameters.databaseUsername) { await t.typeText(this.usernameInput, parameters.databaseUsername, { replace: true, paste: true }); } @@ -128,8 +131,8 @@ export class AddRedisDatabasePage { await t .typeText(this.hostInput, parameters.host, { replace: true, paste: true }) .typeText(this.portInput, parameters.port, { replace: true, paste: true }) - .typeText(this.usernameInput, parameters.databaseUsername, { replace: true, paste: true }) - .typeText(this.passwordInput, parameters.databasePassword, { replace: true, paste: true }); + .typeText(this.usernameInput, parameters.databaseUsername!, { replace: true, paste: true }) + .typeText(this.passwordInput, parameters.databasePassword!, { replace: true, paste: true }); } /** diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index c45ab329e1..a20bfe3d1a 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -199,4 +199,53 @@ export class MyRedisDatabasePage { await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible) .notOk("Database status is still visible"); } + + /** + * Filter array with database objects by result field and return names + * @param listOfDb Actual databases list + * @param result The expected import result + */ + filterDatabaseListByResult(listOfDb: DatabasesForImport, result: string) { + return listOfDb.filter(element => element.result === result).map(item => item.name!); + } } + +/** + * Database for import parameters + * @param host Host of connection + * @param port Port of connection + * @param name The name of connection + * @param result The expected result of connection import + * @param username The username of connection + * @param auth Password of connection + * @param cluster Is the connection has cluster + * @param indName The name of coonection with index + * @param db The index of connection + * @param ssh_port The ssh port of connection + * @param timeout_connect The connect timeout of connection + * @param timeout_execute The execute timeout of connection + * @param other_field The test field + * @param ssl_ca_cert_path The CA certificate of connection by path + * @param ssl_local_cert_path The Client certificate of connection by path + * @param ssl_private_key_path The Client key of connection by path + * @param ssl Is the connection have ssl + */ +export type DatabasesForImport = { + host?: string, + port?: number | string, + name?: string, + result?: string, + username?: string, + auth?: string, + cluster?: boolean | string, + indName?: string, + db?: number, + ssh_port?: number, + timeout_connect?: number, + timeout_execute?: number, + other_field?: string, + ssl_ca_cert_path?: string, + ssl_local_cert_path?: string, + ssl_private_key_path?: string, + ssl?: boolean +}[]; diff --git a/tests/e2e/test-data/ardm-valid.ano b/tests/e2e/test-data/import-databases/ardm-valid.ano similarity index 100% rename from tests/e2e/test-data/ardm-valid.ano rename to tests/e2e/test-data/import-databases/ardm-valid.ano diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index 039eb1ca37..e0af002cb6 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -17,9 +17,9 @@ const racompassInvalidJson = 'racompass-invalid.json'; const rdmFullJson = 'rdm-full.json'; const ardmValidAno = 'ardm-valid.ano'; const listOfDB = JSON.parse(fs.readFileSync(path.join('test-data', 'import-databases', 'rdm-full.json'), 'utf-8')); -const dbSuccessNames = listOfDB.filter(element => element.result === 'success').map(item => item.name); -const dbPartialNames = listOfDB.filter(element => element.result === 'partial').map(item => item.name); -const dbFailedNames = listOfDB.filter(element => element.result === 'failed').map(item => item.name); +const dbSuccessNames = myRedisDatabasePage.filterDatabaseListByResult(listOfDB, 'success'); +const dbPartialNames = myRedisDatabasePage.filterDatabaseListByResult(listOfDB, 'partial'); +const dbFailedNames = myRedisDatabasePage.filterDatabaseListByResult(listOfDB, 'failed'); const dbImportedNames = [...dbSuccessNames, ...dbPartialNames]; const rdmData = { type: 'rdm', @@ -50,18 +50,19 @@ const databases = [ dbData[1].dbNames[1] ]; const databasesToDelete = [...dbImportedNames, ...databases]; +const findImportedDbNameInList = async(dbName: string) => dbImportedNames.find(item => item === dbName)!; fixture `Import databases` - .meta({ type: 'critical_path', rte: rte.standalone }) + .meta({ type: 'critical_path', rte: rte.none }) .page(commonUrl) - .beforeEach(async() => { + .beforeEach(async () => { await acceptLicenseTerms(); }) - .afterEach(async() => { + .afterEach(async () => { // Delete databases deleteStandaloneDatabasesByNamesApi(databasesToDelete); - }); -test('Connection import from JSON', async t => { + }) +test('Connection import modal window', async t => { const tooltipText = 'Import Database Connections'; const defaultText = 'Select or drag and drop a file'; const parseFailedMsg = 'Failed to add database connections'; @@ -95,8 +96,9 @@ test('Connection import from JSON', async t => { await t.click(myRedisDatabasePage.removeImportedFileBtn); await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(defaultText, 'File not removed from import input'); +}); +test('Connection import from JSON', async t => { // Verify that user can import database with mandatory/optional fields - await t.click(myRedisDatabasePage.closeDialogBtn); await databasesActions.importDatabase(rdmData); // Fully imported table @@ -115,14 +117,37 @@ test('Connection import from JSON', async t => { await clickOnEditDatabaseByName(dbImportedNames[1]); // Verify username imported - await t.expect(addRedisDatabasePage.usernameInput.value).eql(listOfDB[1].username); + await t.expect(addRedisDatabasePage.usernameInput.value).eql(listOfDB[1].username, 'Username import incorrect'); // Verify password imported await t.click(addRedisDatabasePage.showPasswordBtn); - await t.expect(addRedisDatabasePage.passwordInput.value).eql(listOfDB[1].auth); + await t.expect(addRedisDatabasePage.passwordInput.value).eql(listOfDB[1].auth, 'Password import incorrect'); // Verify cluster connection type imported await clickOnEditDatabaseByName(dbImportedNames[2]); - await t.expect(addRedisDatabasePage.connectionType.textContent).eql(rdmData.connectionType); + await t.expect(addRedisDatabasePage.connectionType.textContent).eql(rdmData.connectionType, 'Connection type import incorrect'); + + // Verify that user can import database with CA certificate + await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmHost+Port+Name+CaCert')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('ca', 'CA certificate import incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.exists).notOk('Client certificate was imported'); + + // Verify that user can import database with Client certificate, Client private key + await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmHost+Port+Name+clientCert+privateKey')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('No CA Certificate', 'CA certificate was imported'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql('client', 'Client certificate import incorrect'); + + // Verify that user can import database with all certificates + await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmHost+Port+Name+CaCert+clientCert+privateKey')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('ca', 'CA certificate import incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql('client', 'Client certificate import incorrect'); + + // Verify that certificate not imported when any certificate field has not been parsed + await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmCaCertInvalidBody')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('No CA Certificate', 'CA certificate was imported'); + await t.expect(addRedisDatabasePage.clientCertField.exists).notOk('Client certificate was imported'); + await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmInvalidClientCert')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('No CA Certificate', 'CA certificate was imported'); + await t.expect(addRedisDatabasePage.clientCertField.exists).notOk('Client certificate was imported'); // Verify that user can import files from Racompass, ARDM, RDM for (const db of dbData) { From ad7cde621e38b7e4ef7a93ae8b8e23b43d49d4cd Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 20 Dec 2022 15:30:13 +0100 Subject: [PATCH 53/57] tests for import sertificates --- tests/e2e/common-actions/databases-actions.ts | 12 +- tests/e2e/helpers/api/api-database.ts | 8 +- tests/e2e/helpers/database.ts | 4 +- .../pageObjects/add-redis-database-page.ts | 1 + .../pageObjects/my-redis-databases-page.ts | 10 +- .../test-data/certs/certsByPath/caPath.crt | 3 + .../certs/certsByPath/caSameBody.crt | 3 + .../certs/certsByPath/clientPath.crt | 3 + .../certs/certsByPath/clientPath.key | 3 + .../certs/certsByPath/clientSameBody.crt | 3 + .../certs/certsByPath/clientSameBody.key | 3 + .../test-data/certs/sameNameCerts/caPath.crt | 3 + .../certs/sameNameCerts/clientPath.crt | 3 + .../certs/sameNameCerts/clientPath.key | 3 + .../test-data/import-databases/ardm-valid.ano | 2 +- .../import-databases/racompass-valid.json | 2 +- .../import-databases/rdm-certificates.json | 74 ++++++ .../database/import-databases.e2e.ts | 211 +++++++++++------- 18 files changed, 255 insertions(+), 96 deletions(-) create mode 100644 tests/e2e/test-data/certs/certsByPath/caPath.crt create mode 100644 tests/e2e/test-data/certs/certsByPath/caSameBody.crt create mode 100644 tests/e2e/test-data/certs/certsByPath/clientPath.crt create mode 100644 tests/e2e/test-data/certs/certsByPath/clientPath.key create mode 100644 tests/e2e/test-data/certs/certsByPath/clientSameBody.crt create mode 100644 tests/e2e/test-data/certs/certsByPath/clientSameBody.key create mode 100644 tests/e2e/test-data/certs/sameNameCerts/caPath.crt create mode 100644 tests/e2e/test-data/certs/sameNameCerts/clientPath.crt create mode 100644 tests/e2e/test-data/certs/sameNameCerts/clientPath.key create mode 100644 tests/e2e/test-data/import-databases/rdm-certificates.json diff --git a/tests/e2e/common-actions/databases-actions.ts b/tests/e2e/common-actions/databases-actions.ts index 08bea3bda9..277ffed1b6 100644 --- a/tests/e2e/common-actions/databases-actions.ts +++ b/tests/e2e/common-actions/databases-actions.ts @@ -1,4 +1,5 @@ import { t } from 'testcafe'; +import * as fs from 'fs'; import { MyRedisDatabasePage } from '../pageObjects'; const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -26,6 +27,14 @@ export class DatabasesActions { .click(myRedisDatabasePage.submitImportBtn) .expect(myRedisDatabasePage.importDialogTitle.textContent).eql('Import Results', `Databases from ${fileParameters.type} not imported`); } + + /** + * Parse json for importing databases + * @param path The path to json file + */ + parseDbJsonByPath(path: string): any[] { + return JSON.parse(fs.readFileSync(path, 'utf-8')); + } } /** @@ -45,5 +54,6 @@ export type ImportDatabaseParameters = { userName?: string, password?: string, connectionType?: string, - fileName?: string + fileName?: string, + parsedJson?: any }; diff --git a/tests/e2e/helpers/api/api-database.ts b/tests/e2e/helpers/api/api-database.ts index 65b536167c..5288ef54af 100644 --- a/tests/e2e/helpers/api/api-database.ts +++ b/tests/e2e/helpers/api/api-database.ts @@ -62,7 +62,7 @@ export async function addNewOSSClusterDatabaseApi(databaseParameters: OSSCluster export async function discoverSentinelDatabaseApi(databaseParameters: SentinelParameters, primaryGroupsNumber?: number): Promise { let masters = databaseParameters.masters; if (primaryGroupsNumber) { - masters = databaseParameters.masters.slice(0, primaryGroupsNumber); + masters = databaseParameters.masters!.slice(0, primaryGroupsNumber); } const response = await request(endpoint).post('/redis-sentinel/databases') .send({ @@ -126,7 +126,7 @@ export async function getDatabaseByConnectionType(connectionType?: string): Prom export async function deleteAllDatabasesApi(): Promise { const allDatabases = await getAllDatabases(); if (allDatabases.length > 0) { - const databaseIds = []; + const databaseIds: string[] = []; for (let i = 0; i < allDatabases.length; i++) { const dbData = JSON.parse(JSON.stringify(allDatabases[i])); databaseIds.push(dbData.id); @@ -183,8 +183,8 @@ export async function deleteOSSClusterDatabaseApi(databaseParameters: OSSCluster * @param databaseParameters The database parameters */ export async function deleteAllSentinelDatabasesApi(databaseParameters: SentinelParameters): Promise { - for (let i = 0; i < databaseParameters.name.length; i++) { - const databaseId = await getDatabaseByName(databaseParameters.name[i]); + for (let i = 0; i < databaseParameters.name!.length; i++) { + const databaseId = await getDatabaseByName(databaseParameters.name![i]); const response = await request(endpoint).delete('/databases') .send({ 'ids': [`${databaseId}`] }).set('Accept', 'application/json'); await t.expect(response.status).eql(200, 'Delete Sentinel database request failed'); diff --git a/tests/e2e/helpers/database.ts b/tests/e2e/helpers/database.ts index 6e39f67541..851bf5c8ca 100644 --- a/tests/e2e/helpers/database.ts +++ b/tests/e2e/helpers/database.ts @@ -158,7 +158,7 @@ export async function acceptLicenseTermsAndAddSentinelDatabaseApi(databaseParame // Reload Page to see the database added through api await common.reloadPage(); // Connect to DB - await myRedisDatabasePage.clickOnDBByName(databaseParameters.name[1] ?? ''); + await myRedisDatabasePage.clickOnDBByName(databaseParameters.name![1] ?? ''); } /** @@ -273,7 +273,7 @@ export async function clickOnEditDatabaseByName(databaseName: string): Promise { +export async function deleteDatabaseByNameApi(databaseName: string): Promise { const databaseId = await getDatabaseByName(databaseName); const databaseDeleteBtn = Selector(`[data-testid=delete-instance-${databaseId}-icon]`); diff --git a/tests/e2e/pageObjects/add-redis-database-page.ts b/tests/e2e/pageObjects/add-redis-database-page.ts index 92a6f68350..fc71fb7a1d 100644 --- a/tests/e2e/pageObjects/add-redis-database-page.ts +++ b/tests/e2e/pageObjects/add-redis-database-page.ts @@ -44,6 +44,7 @@ export class AddRedisDatabasePage { primaryGroupNameInput = Selector('[data-testid=primary-group]'); masterGroupPassword = Selector('[data-testid=sentinel-master-password]'); connectionType = Selector('[data-testid=connection-type]'); + sentinelForm = Selector('[data-testid=form]'); //Links buildFromSource = Selector('a').withExactText('Build from source'); buildFromDocker = Selector('a').withExactText('Docker'); diff --git a/tests/e2e/pageObjects/my-redis-databases-page.ts b/tests/e2e/pageObjects/my-redis-databases-page.ts index a20bfe3d1a..23797c4bc8 100644 --- a/tests/e2e/pageObjects/my-redis-databases-page.ts +++ b/tests/e2e/pageObjects/my-redis-databases-page.ts @@ -188,16 +188,16 @@ export class MyRedisDatabasePage { * Verify database status is visible */ async verifyDatabaseStatusIsVisible(): Promise { - await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible) - .ok("Database status is not visible"); + await t.expect(Selector('div').withAttribute('data-testid', /database-status-new-*/).visible) + .ok('Database status is not visible'); } /** * Verify database status is not visible */ async verifyDatabaseStatusIsNotVisible(): Promise { - await t.expect(Selector("div").withAttribute("data-testid", /database-status-new-*/).visible) - .notOk("Database status is still visible"); + await t.expect(Selector('div').withAttribute('data-testid', /database-status-new-*/).visible) + .notOk('Database status is still visible'); } /** @@ -205,7 +205,7 @@ export class MyRedisDatabasePage { * @param listOfDb Actual databases list * @param result The expected import result */ - filterDatabaseListByResult(listOfDb: DatabasesForImport, result: string) { + getDatabaseNamesFromListByResult(listOfDb: DatabasesForImport, result: string): string[] { return listOfDb.filter(element => element.result === result).map(item => item.name!); } } diff --git a/tests/e2e/test-data/certs/certsByPath/caPath.crt b/tests/e2e/test-data/certs/certsByPath/caPath.crt new file mode 100644 index 0000000000..a2e4e8dde6 --- /dev/null +++ b/tests/e2e/test-data/certs/certsByPath/caPath.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +mockedCACertificatePath +-----END CERTIFICATE----- diff --git a/tests/e2e/test-data/certs/certsByPath/caSameBody.crt b/tests/e2e/test-data/certs/certsByPath/caSameBody.crt new file mode 100644 index 0000000000..a2e4e8dde6 --- /dev/null +++ b/tests/e2e/test-data/certs/certsByPath/caSameBody.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +mockedCACertificatePath +-----END CERTIFICATE----- diff --git a/tests/e2e/test-data/certs/certsByPath/clientPath.crt b/tests/e2e/test-data/certs/certsByPath/clientPath.crt new file mode 100644 index 0000000000..7db9b4d04f --- /dev/null +++ b/tests/e2e/test-data/certs/certsByPath/clientPath.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +mockedClientCrtPath +-----END CERTIFICATE----- diff --git a/tests/e2e/test-data/certs/certsByPath/clientPath.key b/tests/e2e/test-data/certs/certsByPath/clientPath.key new file mode 100644 index 0000000000..9df04e8724 --- /dev/null +++ b/tests/e2e/test-data/certs/certsByPath/clientPath.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +mockedPrivateKeyPath +-----END PRIVATE KEY----- diff --git a/tests/e2e/test-data/certs/certsByPath/clientSameBody.crt b/tests/e2e/test-data/certs/certsByPath/clientSameBody.crt new file mode 100644 index 0000000000..7db9b4d04f --- /dev/null +++ b/tests/e2e/test-data/certs/certsByPath/clientSameBody.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +mockedClientCrtPath +-----END CERTIFICATE----- diff --git a/tests/e2e/test-data/certs/certsByPath/clientSameBody.key b/tests/e2e/test-data/certs/certsByPath/clientSameBody.key new file mode 100644 index 0000000000..9df04e8724 --- /dev/null +++ b/tests/e2e/test-data/certs/certsByPath/clientSameBody.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +mockedPrivateKeyPath +-----END PRIVATE KEY----- diff --git a/tests/e2e/test-data/certs/sameNameCerts/caPath.crt b/tests/e2e/test-data/certs/sameNameCerts/caPath.crt new file mode 100644 index 0000000000..81c5565ebc --- /dev/null +++ b/tests/e2e/test-data/certs/sameNameCerts/caPath.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +mockedCACertificatePath1 +-----END CERTIFICATE----- diff --git a/tests/e2e/test-data/certs/sameNameCerts/clientPath.crt b/tests/e2e/test-data/certs/sameNameCerts/clientPath.crt new file mode 100644 index 0000000000..192ff845d1 --- /dev/null +++ b/tests/e2e/test-data/certs/sameNameCerts/clientPath.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +mockedClientCrtPath1 +-----END CERTIFICATE----- diff --git a/tests/e2e/test-data/certs/sameNameCerts/clientPath.key b/tests/e2e/test-data/certs/sameNameCerts/clientPath.key new file mode 100644 index 0000000000..087fbe9c5b --- /dev/null +++ b/tests/e2e/test-data/certs/sameNameCerts/clientPath.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +mockedPrivateKeyPath1 +-----END PRIVATE KEY----- diff --git a/tests/e2e/test-data/import-databases/ardm-valid.ano b/tests/e2e/test-data/import-databases/ardm-valid.ano index 7c557d1989..cb5e8862bb 100644 --- a/tests/e2e/test-data/import-databases/ardm-valid.ano +++ b/tests/e2e/test-data/import-databases/ardm-valid.ano @@ -1 +1 @@ -W3siaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiI2Mzc5IiwiYXV0aCI6InBhc3MiLCJ1c2VybmFtZSI6InVzZXJuYW1lVGVzdCIsIm5hbWUiOiJhcmRtV2l0aFBhc3NBbmRVc2VybmFtZSIsInNlcGFyYXRvciI6IjoiLCJjbHVzdGVyIjpmYWxzZSwia2V5IjoiMTY1MDM3MzUyNTY1MV9naHFwciIsIm9yZGVyIjowfSx7Imhvc3QiOiJhcmRtTm9OYW1lIiwicG9ydCI6IjEyMDAxIiwiYXV0aCI6IiIsInVzZXJuYW1lIjoiIiwic2VwYXJhdG9yIjoiOiIsImNsdXN0ZXIiOmZhbHNlLCJrZXkiOiIxNjUwODk3NjIxNzU0X2I1bjB2Iiwib3JkZXIiOjF9XQ== \ No newline at end of file +W3siaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiI2Mzc5IiwiYXV0aCI6InBhc3MiLCJ1c2VybmFtZSI6InVzZXJuYW1lVGVzdCIsIm5hbWUiOiJhcmRtV2l0aFBhc3NBbmRVc2VybmFtZSIsInNlcGFyYXRvciI6IjoiLCJjbHVzdGVyIjpmYWxzZSwia2V5IjoiMTY1MDM3MzUyNTY1MV9naHFwciIsIm9yZGVyIjowfSx7Imhvc3QiOiJhcmRtTm9OYW1lIiwicG9ydCI6IjEyMDAxIiwiYXV0aCI6IiIsInVzZXJuYW1lIjoiIiwic2VwYXJhdG9yIjoiOiIsImNsdXN0ZXIiOmZhbHNlLCJrZXkiOiIxNjUwODk3NjIxNzU0X2I1bjB2Iiwib3JkZXIiOjF9LHsiaG9zdCI6Im9zcy1zZW50aW5lbCIsInBvcnQiOiIyNjM3OSIsIm5hbWUiOiJhcmRtU2VudGluZWwiLCJhdXRoIjoiZGVmYXVsdHBhc3MiLCJzZW50aW5lbE9wdGlvbnMiOnsibWFzdGVyTmFtZSI6InByaW1hcnktZ3JvdXAtMSIsIm5vZGVQYXNzd29yZCI6ImRlZmF1bHRwYXNzIn19XQ== \ No newline at end of file diff --git a/tests/e2e/test-data/import-databases/racompass-valid.json b/tests/e2e/test-data/import-databases/racompass-valid.json index dc4e32dcf5..7cac1af195 100644 --- a/tests/e2e/test-data/import-databases/racompass-valid.json +++ b/tests/e2e/test-data/import-databases/racompass-valid.json @@ -165,7 +165,7 @@ "port": 1111, "keyPrefix": null, "id": "f99a5d6d-daf4-489c-885b-6f8e411adbc9", - "cluster": true, + "type": "cluster", "name": "racompassCluster" } ] \ No newline at end of file diff --git a/tests/e2e/test-data/import-databases/rdm-certificates.json b/tests/e2e/test-data/import-databases/rdm-certificates.json new file mode 100644 index 0000000000..bd6f2daac9 --- /dev/null +++ b/tests/e2e/test-data/import-databases/rdm-certificates.json @@ -0,0 +1,74 @@ +[ + { + "host": "localhost", + "port": 8102, + "name": "theSameBody1", + "caCert": { + "name": "testCaName", + "certificate": "-----BEGIN CERTIFICATE-----mockedCACertificate1-----END CERTIFICATE-----" + }, + "clientCert": { + "name": "testClientCertName", + "certificate": "-----BEGIN CERTIFICATE-----mockedClientCertificate1-----END CERTIFICATE-----", + "key": "-----BEGIN PRIVATE KEY-----mockedClientKey1-----END PRIVATE KEY-----" + }, + "result": "success" + }, + { + "host": "localhost", + "port": 8101, + "name": "theSameBody2", + "caCert": { + "name": "testCaName2", + "certificate": "-----BEGIN CERTIFICATE-----mockedCACertificate1-----END CERTIFICATE-----" + }, + "clientCert": { + "name": "testClientCertName2", + "certificate": "-----BEGIN CERTIFICATE-----mockedClientCertificate1-----END CERTIFICATE-----", + "key": "-----BEGIN PRIVATE KEY-----mockedClientKey1-----END PRIVATE KEY-----" + }, + "result": "success" + }, + { + "host": "localhost", + "port": 8103, + "name": "theSameName", + "caCert": { + "name": "testCaName", + "certificate": "-----BEGIN CERTIFICATE-----mockedCACertificate2-----END CERTIFICATE-----" + }, + "clientCert": { + "name": "testClientCertName", + "certificate": "-----BEGIN CERTIFICATE-----mockedClientCertificate2-----END CERTIFICATE-----", + "key": "-----BEGIN PRIVATE KEY-----mockedClientKey2-----END PRIVATE KEY-----" + }, + "result": "success" + }, + { + "host": "localhost", + "port": 8102, + "name": "theSameBody1Path", + "ssl_ca_cert_path": "/root/certs/certsByPath/caPath.crt", + "ssl_local_cert_path": "/root/certs/certsByPath/clientPath.crt", + "ssl_private_key_path": "/root/certs/certsByPath/clientPath.key", + "result": "success" + }, + { + "host": "localhost", + "port": 8101, + "name": "theSameBody2Path", + "ssl_ca_cert_path": "/root/certs/certsByPath/caSameBody.crt", + "ssl_local_cert_path": "/root/certs/certsByPath/clientSameBody.crt", + "ssl_private_key_path": "/root/certs/certsByPath/clientSameBody.key", + "result": "success" + }, + { + "host": "localhost", + "port": 8103, + "name": "theSameNamePath", + "ssl_ca_cert_path": "/root/certs/sameNameCerts/caPath.crt", + "ssl_local_cert_path": "/root/certs/sameNameCerts/clientPath.crt", + "ssl_private_key_path": "/root/certs/sameNameCerts/clientPath.key", + "result": "success" + } +] \ No newline at end of file diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index e0af002cb6..e9e32f44c6 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -1,4 +1,3 @@ -import * as fs from 'fs'; import * as path from 'path'; import { rte } from '../../../helpers/constants'; import { AddRedisDatabasePage, BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; @@ -15,19 +14,28 @@ const addRedisDatabasePage = new AddRedisDatabasePage(); const racompassValidJson = 'racompass-valid.json'; const racompassInvalidJson = 'racompass-invalid.json'; const rdmFullJson = 'rdm-full.json'; +const rdmCertsJson = 'rdm-certificates.json'; const ardmValidAno = 'ardm-valid.ano'; -const listOfDB = JSON.parse(fs.readFileSync(path.join('test-data', 'import-databases', 'rdm-full.json'), 'utf-8')); -const dbSuccessNames = myRedisDatabasePage.filterDatabaseListByResult(listOfDB, 'success'); -const dbPartialNames = myRedisDatabasePage.filterDatabaseListByResult(listOfDB, 'partial'); -const dbFailedNames = myRedisDatabasePage.filterDatabaseListByResult(listOfDB, 'failed'); -const dbImportedNames = [...dbSuccessNames, ...dbPartialNames]; +const racompassInvalidJsonPath = path.join('..', '..', '..', 'test-data', 'import-databases', racompassInvalidJson); +const rdmListOfDB = databasesActions.parseDbJsonByPath(path.join('test-data', 'import-databases', rdmFullJson)); +const rdmCertsListOfDB = databasesActions.parseDbJsonByPath(path.join('test-data', 'import-databases', rdmCertsJson)); +const rdmSuccessNames = myRedisDatabasePage.getDatabaseNamesFromListByResult(rdmListOfDB, 'success'); +const rdmPartialNames = myRedisDatabasePage.getDatabaseNamesFromListByResult(rdmListOfDB, 'partial'); +const rdmFailedNames = myRedisDatabasePage.getDatabaseNamesFromListByResult(rdmListOfDB, 'failed'); +const rdmCertsNames = myRedisDatabasePage.getDatabaseNamesFromListByResult(rdmCertsListOfDB, 'success'); const rdmData = { type: 'rdm', path: path.join('..', '..', '..', 'test-data', 'import-databases', rdmFullJson), connectionType: 'Cluster', - successNumber: dbSuccessNames.length, - partialNumber: dbPartialNames.length, - failedNumber: dbFailedNames.length + successNumber: rdmSuccessNames.length, + partialNumber: rdmPartialNames.length, + failedNumber: rdmFailedNames.length, + dbImportedNames: [...rdmSuccessNames, ...rdmPartialNames] +}; +const rdmCertsData = { + type: 'rdm', + path: path.join('..', '..', '..', 'test-data', 'import-databases', rdmCertsJson), + parsedJson: databasesActions.parseDbJsonByPath(path.join('test-data', 'import-databases', rdmCertsJson)) }; const dbData = [ { @@ -38,30 +46,22 @@ const dbData = [ { type: 'ardm', path: path.join('..', '..', '..', 'test-data', 'import-databases', ardmValidAno), - dbNames: ['ardmNoName:12001', 'ardmWithPassAndUsername'] + dbNames: ['ardmNoName:12001', 'ardmWithPassAndUsername', 'ardmSentinel'] } ]; -const racompassInvalidJsonPath = path.join('..', '..', '..', 'test-data', 'import-databases', racompassInvalidJson); -// List of all created databases to delete -const databases = [ +const databasesToDelete = [ dbData[0].dbNames[0], dbData[0].dbNames[1].split(' ')[0], - dbData[1].dbNames[0], - dbData[1].dbNames[1] + ...dbData[1].dbNames ]; -const databasesToDelete = [...dbImportedNames, ...databases]; -const findImportedDbNameInList = async(dbName: string) => dbImportedNames.find(item => item === dbName)!; +const findImportedRdmDbNameInList = async(dbName: string): Promise => rdmData.dbImportedNames.find(item => item === dbName)!; fixture `Import databases` .meta({ type: 'critical_path', rte: rte.none }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTerms(); - }) - .afterEach(async () => { - // Delete databases - deleteStandaloneDatabasesByNamesApi(databasesToDelete); - }) + }); test('Connection import modal window', async t => { const tooltipText = 'Import Database Connections'; const defaultText = 'Select or drag and drop a file'; @@ -95,64 +95,111 @@ test('Connection import modal window', async t => { // Click on remove button await t.click(myRedisDatabasePage.removeImportedFileBtn); await t.expect(myRedisDatabasePage.importDbDialog.textContent).contains(defaultText, 'File not removed from import input'); - }); -test('Connection import from JSON', async t => { - // Verify that user can import database with mandatory/optional fields - await databasesActions.importDatabase(rdmData); - - // Fully imported table - await t.expect(myRedisDatabasePage.successResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) - .contains(`${rdmData.successNumber}`, 'Not correct successfully imported number'); - // Partially imported table - await t.expect(myRedisDatabasePage.partialResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) - .contains(`${rdmData.partialNumber}`, 'Not correct partially imported number'); - // Failed to import table - await t.expect(myRedisDatabasePage.failedResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) - .contains(`${rdmData.failedNumber}`, 'Not correct import failed number'); - - // Verify that list of databases is reloaded when database added - await t.click(myRedisDatabasePage.okDialogBtn); - await databasesActions.verifyDatabasesDisplayed(dbImportedNames); - - await clickOnEditDatabaseByName(dbImportedNames[1]); - // Verify username imported - await t.expect(addRedisDatabasePage.usernameInput.value).eql(listOfDB[1].username, 'Username import incorrect'); - // Verify password imported - await t.click(addRedisDatabasePage.showPasswordBtn); - await t.expect(addRedisDatabasePage.passwordInput.value).eql(listOfDB[1].auth, 'Password import incorrect'); - - // Verify cluster connection type imported - await clickOnEditDatabaseByName(dbImportedNames[2]); - await t.expect(addRedisDatabasePage.connectionType.textContent).eql(rdmData.connectionType, 'Connection type import incorrect'); - - // Verify that user can import database with CA certificate - await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmHost+Port+Name+CaCert')); - await t.expect(addRedisDatabasePage.caCertField.textContent).eql('ca', 'CA certificate import incorrect'); - await t.expect(addRedisDatabasePage.clientCertField.exists).notOk('Client certificate was imported'); - - // Verify that user can import database with Client certificate, Client private key - await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmHost+Port+Name+clientCert+privateKey')); - await t.expect(addRedisDatabasePage.caCertField.textContent).eql('No CA Certificate', 'CA certificate was imported'); - await t.expect(addRedisDatabasePage.clientCertField.textContent).eql('client', 'Client certificate import incorrect'); - - // Verify that user can import database with all certificates - await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmHost+Port+Name+CaCert+clientCert+privateKey')); - await t.expect(addRedisDatabasePage.caCertField.textContent).eql('ca', 'CA certificate import incorrect'); - await t.expect(addRedisDatabasePage.clientCertField.textContent).eql('client', 'Client certificate import incorrect'); - - // Verify that certificate not imported when any certificate field has not been parsed - await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmCaCertInvalidBody')); - await t.expect(addRedisDatabasePage.caCertField.textContent).eql('No CA Certificate', 'CA certificate was imported'); - await t.expect(addRedisDatabasePage.clientCertField.exists).notOk('Client certificate was imported'); - await clickOnEditDatabaseByName(await findImportedDbNameInList('rdmInvalidClientCert')); - await t.expect(addRedisDatabasePage.caCertField.textContent).eql('No CA Certificate', 'CA certificate was imported'); - await t.expect(addRedisDatabasePage.clientCertField.exists).notOk('Client certificate was imported'); - - // Verify that user can import files from Racompass, ARDM, RDM - for (const db of dbData) { - await databasesActions.importDatabase(db); +test + .after(async() => { + // Delete databases + await deleteStandaloneDatabasesByNamesApi([...rdmData.dbImportedNames, ...databasesToDelete]); + })('Connection import from JSON', async t => { + // Verify that user can import database with mandatory/optional fields + await databasesActions.importDatabase(rdmData); + + // Fully imported table + await t.expect(myRedisDatabasePage.successResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.successNumber}`, 'Not correct successfully imported number'); + // Partially imported table + await t.expect(myRedisDatabasePage.partialResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.partialNumber}`, 'Not correct partially imported number'); + // Failed to import table + await t.expect(myRedisDatabasePage.failedResultsAccordion.find(myRedisDatabasePage.cssNumberOfDbs).textContent) + .contains(`${rdmData.failedNumber}`, 'Not correct import failed number'); + + // Verify that list of databases is reloaded when database added await t.click(myRedisDatabasePage.okDialogBtn); - await databasesActions.verifyDatabasesDisplayed(db.dbNames); - } -}); + await databasesActions.verifyDatabasesDisplayed(rdmData.dbImportedNames); + + await clickOnEditDatabaseByName(rdmData.dbImportedNames[1]); + // Verify username imported + await t.expect(addRedisDatabasePage.usernameInput.value).eql(rdmListOfDB[1].username, 'Username import incorrect'); + // Verify password imported + await t.click(addRedisDatabasePage.showPasswordBtn); + await t.expect(addRedisDatabasePage.passwordInput.value).eql(rdmListOfDB[1].auth, 'Password import incorrect'); + + // Verify cluster connection type imported + await clickOnEditDatabaseByName(rdmData.dbImportedNames[2]); + await t.expect(addRedisDatabasePage.connectionType.textContent).eql(rdmData.connectionType, 'Connection type import incorrect'); + + /* + Verify that user can import database with CA certificate + Verify that user can import database with certificates by an absolute folder path(CA certificate, Client certificate, Client private key) + Verify that user can see the certificate name as the certificate file name + */ + await clickOnEditDatabaseByName(await findImportedRdmDbNameInList('rdmHost+Port+Name+CaCert')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('ca', 'CA certificate import incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.exists).notOk('Client certificate was imported'); + + // Verify that user can import database with Client certificate, Client private key + await clickOnEditDatabaseByName(await findImportedRdmDbNameInList('rdmHost+Port+Name+clientCert+privateKey')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('No CA Certificate', 'CA certificate was imported'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql('client', 'Client certificate import incorrect'); + + // Verify that user can import database with all certificates + await clickOnEditDatabaseByName(await findImportedRdmDbNameInList('rdmHost+Port+Name+CaCert+clientCert+privateKey')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('ca', 'CA certificate import incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql('client', 'Client certificate import incorrect'); + + // Verify that certificate not imported when any certificate field has not been parsed + await clickOnEditDatabaseByName(await findImportedRdmDbNameInList('rdmCaCertInvalidBody')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('No CA Certificate', 'CA certificate was imported'); + await t.expect(addRedisDatabasePage.clientCertField.exists).notOk('Client certificate was imported'); + await clickOnEditDatabaseByName(await findImportedRdmDbNameInList('rdmInvalidClientCert')); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('No CA Certificate', 'CA certificate was imported'); + await t.expect(addRedisDatabasePage.clientCertField.exists).notOk('Client certificate was imported'); + + // Verify that user can import files from Racompass, ARDM, RDM + for (const db of dbData) { + await databasesActions.importDatabase(db); + await t.click(myRedisDatabasePage.okDialogBtn); + await databasesActions.verifyDatabasesDisplayed(db.dbNames); + } + + // Verify that user can import Sentinel database connections by corresponding fields in JSON + await clickOnEditDatabaseByName(dbData[1].dbNames[2]); + await t.expect(addRedisDatabasePage.sentinelForm.textContent).contains('Sentinel', 'Sentinel connection type import incorrect'); + }); +test + .after(async() => { + // Delete databases + await deleteStandaloneDatabasesByNamesApi(rdmCertsNames); + })('Certificates import with/without path', async t => { + await databasesActions.importDatabase(rdmCertsData); + await t.click(myRedisDatabasePage.okDialogBtn); + + // Verify that when user imports a certificate and the same certificate body already exists, the existing certificate (with its name) is applied + await clickOnEditDatabaseByName(rdmCertsData.parsedJson[0].name); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql(rdmCertsData.parsedJson[0].caCert.name, 'CA certificate import incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql(rdmCertsData.parsedJson[0].clientCert.name, 'Client certificate import incorrect'); + + await clickOnEditDatabaseByName(rdmCertsData.parsedJson[1].name); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql(rdmCertsData.parsedJson[0].caCert.name, 'CA certificate name with the same body is incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql(rdmCertsData.parsedJson[0].clientCert.name, 'Client certificate name with the same body is incorrect'); + + // Verify that when user imports a certificate and the same certificate name exists but with a different body, the certificate imported with "({incremental_number})_certificate_name" name + await clickOnEditDatabaseByName(rdmCertsData.parsedJson[2].name); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql(`1_${rdmCertsData.parsedJson[0].caCert.name}`, 'CA certificate name with the same body is incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql(`1_${rdmCertsData.parsedJson[0].clientCert.name}`, 'Client certificate name with the same body is incorrect'); + + // Verify that when user imports a certificate by path and the same certificate body already exists, the existing certificate (with its name) is applied + await clickOnEditDatabaseByName(rdmCertsData.parsedJson[3].name); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('caPath', 'CA certificate import incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql('clientPath', 'Client certificate import incorrect'); + + await clickOnEditDatabaseByName(rdmCertsData.parsedJson[4].name); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('caPath', 'CA certificate import incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql('clientPath', 'Client certificate import incorrect'); + + // Verify that when user imports a certificate by path and the same certificate name exists but with a different body, the certificate imported with "({incremental_number})certificate_name" name + await clickOnEditDatabaseByName(rdmCertsData.parsedJson[5].name); + await t.expect(addRedisDatabasePage.caCertField.textContent).eql('1_caPath', 'CA certificate import incorrect'); + await t.expect(addRedisDatabasePage.clientCertField.textContent).eql('1_clientPath', 'Client certificate import incorrect'); + }); From 3b2af69d3a9e9482bccc2590a06a014a2fd66759 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 20 Dec 2022 17:34:04 +0100 Subject: [PATCH 54/57] pr fix --- tests/e2e/common-actions/databases-actions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/common-actions/databases-actions.ts b/tests/e2e/common-actions/databases-actions.ts index 277ffed1b6..043966c937 100644 --- a/tests/e2e/common-actions/databases-actions.ts +++ b/tests/e2e/common-actions/databases-actions.ts @@ -46,6 +46,7 @@ export class DatabasesActions { * @param password The password of db * @param connectionType The connection type of db * @param fileName The file name + * @param parsedJson The parsed json content */ export type ImportDatabaseParameters = { path: string, From b5b40457cbbd6f13e43acc3dbf6b1c3ed87393ed Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 20 Dec 2022 17:49:30 +0100 Subject: [PATCH 55/57] add connection to sentinel --- tests/e2e/test-data/import-databases/ardm-valid.ano | 2 +- .../e2e/tests/critical-path/database/import-databases.e2e.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test-data/import-databases/ardm-valid.ano b/tests/e2e/test-data/import-databases/ardm-valid.ano index cb5e8862bb..3a18087819 100644 --- a/tests/e2e/test-data/import-databases/ardm-valid.ano +++ b/tests/e2e/test-data/import-databases/ardm-valid.ano @@ -1 +1 @@ -W3siaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiI2Mzc5IiwiYXV0aCI6InBhc3MiLCJ1c2VybmFtZSI6InVzZXJuYW1lVGVzdCIsIm5hbWUiOiJhcmRtV2l0aFBhc3NBbmRVc2VybmFtZSIsInNlcGFyYXRvciI6IjoiLCJjbHVzdGVyIjpmYWxzZSwia2V5IjoiMTY1MDM3MzUyNTY1MV9naHFwciIsIm9yZGVyIjowfSx7Imhvc3QiOiJhcmRtTm9OYW1lIiwicG9ydCI6IjEyMDAxIiwiYXV0aCI6IiIsInVzZXJuYW1lIjoiIiwic2VwYXJhdG9yIjoiOiIsImNsdXN0ZXIiOmZhbHNlLCJrZXkiOiIxNjUwODk3NjIxNzU0X2I1bjB2Iiwib3JkZXIiOjF9LHsiaG9zdCI6Im9zcy1zZW50aW5lbCIsInBvcnQiOiIyNjM3OSIsIm5hbWUiOiJhcmRtU2VudGluZWwiLCJhdXRoIjoiZGVmYXVsdHBhc3MiLCJzZW50aW5lbE9wdGlvbnMiOnsibWFzdGVyTmFtZSI6InByaW1hcnktZ3JvdXAtMSIsIm5vZGVQYXNzd29yZCI6ImRlZmF1bHRwYXNzIn19XQ== \ No newline at end of file +W3siaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiI2Mzc5IiwiYXV0aCI6InBhc3MiLCJ1c2VybmFtZSI6InVzZXJuYW1lVGVzdCIsIm5hbWUiOiJhcmRtV2l0aFBhc3NBbmRVc2VybmFtZSIsInNlcGFyYXRvciI6IjoiLCJjbHVzdGVyIjpmYWxzZSwia2V5IjoiMTY1MDM3MzUyNTY1MV9naHFwciIsIm9yZGVyIjowfSx7Imhvc3QiOiJhcmRtTm9OYW1lIiwicG9ydCI6IjEyMDAxIiwiYXV0aCI6IiIsInVzZXJuYW1lIjoiIiwic2VwYXJhdG9yIjoiOiIsImNsdXN0ZXIiOmZhbHNlLCJrZXkiOiIxNjUwODk3NjIxNzU0X2I1bjB2Iiwib3JkZXIiOjF9LHsiaG9zdCI6Im9zcy1zZW50aW5lbCIsInBvcnQiOiIyNjM3OSIsIm5hbWUiOiJhcmRtU2VudGluZWwiLCJhdXRoIjoicGFzc3dvcmQiLCJzZW50aW5lbE9wdGlvbnMiOnsibWFzdGVyTmFtZSI6InByaW1hcnktZ3JvdXAtMSIsIm5vZGVQYXNzd29yZCI6ImRlZmF1bHRwYXNzIn19XQ== \ No newline at end of file diff --git a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts index e9e32f44c6..ce46029b7f 100644 --- a/tests/e2e/tests/critical-path/database/import-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import { ClientFunction } from 'testcafe'; import { rte } from '../../../helpers/constants'; import { AddRedisDatabasePage, BrowserPage, MyRedisDatabasePage } from '../../../pageObjects'; import { commonUrl } from '../../../helpers/conf'; @@ -55,6 +56,8 @@ const databasesToDelete = [ ...dbData[1].dbNames ]; const findImportedRdmDbNameInList = async(dbName: string): Promise => rdmData.dbImportedNames.find(item => item === dbName)!; +// Returns the URL of the current web page +const getPageUrl = ClientFunction(() => window.location.href); fixture `Import databases` .meta({ type: 'critical_path', rte: rte.none }) @@ -166,6 +169,8 @@ test // Verify that user can import Sentinel database connections by corresponding fields in JSON await clickOnEditDatabaseByName(dbData[1].dbNames[2]); await t.expect(addRedisDatabasePage.sentinelForm.textContent).contains('Sentinel', 'Sentinel connection type import incorrect'); + await myRedisDatabasePage.clickOnDBByName(dbData[1].dbNames[2]); + await t.expect(getPageUrl()).contains('browser', 'Sentinel connection not opened'); }); test .after(async() => { From cb30d5edc5a38c9bab4895457f64ee777aefcecc Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 20 Dec 2022 20:50:41 +0200 Subject: [PATCH 56/57] possible solution for editing databases --- .../modules/database/database.controller.ts | 41 +- .../src/modules/database/database.service.ts | 3 +- .../database/dto/modify.database.dto.ts | 20 + .../database/dto/update.database.dto.ts | 96 ++- .../api/database/PATCH-databases-id.test.ts | 774 ++++++++++++++++++ .../ui/src/slices/instances/instances.ts | 2 +- 6 files changed, 929 insertions(+), 7 deletions(-) create mode 100644 redisinsight/api/src/modules/database/dto/modify.database.dto.ts create mode 100644 redisinsight/api/test/api/database/PATCH-databases-id.test.ts diff --git a/redisinsight/api/src/modules/database/database.controller.ts b/redisinsight/api/src/modules/database/database.controller.ts index b4f86075eb..532b512520 100644 --- a/redisinsight/api/src/modules/database/database.controller.ts +++ b/redisinsight/api/src/modules/database/database.controller.ts @@ -1,7 +1,17 @@ import { ApiTags } from '@nestjs/swagger'; import { Body, - ClassSerializerInterceptor, Controller, Delete, Get, Param, Post, Put, UseInterceptors, UsePipes, ValidationPipe, + ClassSerializerInterceptor, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, + UseInterceptors, + UsePipes, + ValidationPipe, } from '@nestjs/common'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; import { Database } from 'src/modules/database/models/database'; @@ -16,6 +26,7 @@ import { DeleteDatabasesDto } from 'src/modules/database/dto/delete.databases.dt import { DeleteDatabasesResponse } from 'src/modules/database/dto/delete.databases.response'; import { ClientMetadataParam } from 'src/common/decorators'; import { ClientMetadata } from 'src/common/models'; +import { ModifyDatabaseDto } from 'src/modules/database/dto/modify.database.dto'; @ApiTags('Database') @Controller('databases') @@ -117,6 +128,34 @@ export class DatabaseController { return await this.service.update(id, database, true); } + @UseInterceptors(ClassSerializerInterceptor) + @UseInterceptors(new TimeoutInterceptor(ERROR_MESSAGES.CONNECTION_TIMEOUT)) + @Patch(':id') + @ApiEndpoint({ + description: 'Update database instance by id', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Updated database instance\' response', + type: Database, + }, + ], + }) + @UsePipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ) + async modify( + @Param('id') id: string, + @Body() database: ModifyDatabaseDto, + ): Promise { + return await this.service.update(id, database, true); + } + @Delete('/:id') @ApiEndpoint({ statusCode: 200, diff --git a/redisinsight/api/src/modules/database/database.service.ts b/redisinsight/api/src/modules/database/database.service.ts index 8c9800fc85..6f5aaacb7b 100644 --- a/redisinsight/api/src/modules/database/database.service.ts +++ b/redisinsight/api/src/modules/database/database.service.ts @@ -18,6 +18,7 @@ import { AppRedisInstanceEvents } from 'src/constants'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { DeleteDatabasesResponse } from 'src/modules/database/dto/delete.databases.response'; import { ClientContext } from 'src/common/models'; +import { ModifyDatabaseDto } from 'src/modules/database/dto/modify.database.dto'; @Injectable() export class DatabaseService { @@ -118,7 +119,7 @@ export class DatabaseService { // todo: remove manualUpdate flag logic public async update( id: string, - dto: UpdateDatabaseDto, + dto: UpdateDatabaseDto | ModifyDatabaseDto, manualUpdate: boolean = true, ): Promise { this.logger.log(`Updating database: ${id}`); diff --git a/redisinsight/api/src/modules/database/dto/modify.database.dto.ts b/redisinsight/api/src/modules/database/dto/modify.database.dto.ts new file mode 100644 index 0000000000..a85e018b43 --- /dev/null +++ b/redisinsight/api/src/modules/database/dto/modify.database.dto.ts @@ -0,0 +1,20 @@ +import { CreateDatabaseDto } from 'src/modules/database/dto/create.database.dto'; +import { PartialType } from '@nestjs/swagger'; +import { + IsInt, IsString, MaxLength, ValidateIf, +} from 'class-validator'; + +export class ModifyDatabaseDto extends PartialType(CreateDatabaseDto) { + @ValidateIf((object, value) => value !== undefined) + @IsString({ always: true }) + @MaxLength(500) + name: string; + + @ValidateIf((object, value) => value !== undefined) + @IsString({ always: true }) + host: string; + + @ValidateIf((object, value) => value !== undefined) + @IsInt({ always: true }) + port: number; +} diff --git a/redisinsight/api/src/modules/database/dto/update.database.dto.ts b/redisinsight/api/src/modules/database/dto/update.database.dto.ts index 1f50ed47f3..9946f1a5cf 100644 --- a/redisinsight/api/src/modules/database/dto/update.database.dto.ts +++ b/redisinsight/api/src/modules/database/dto/update.database.dto.ts @@ -1,10 +1,20 @@ -import { CreateDatabaseDto } from 'src/modules/database/dto/create.database.dto'; -import { PartialType } from '@nestjs/swagger'; +import { ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { - IsInt, IsString, MaxLength, ValidateIf, + IsBoolean, + IsInt, IsNotEmpty, IsNotEmptyObject, IsOptional, IsString, MaxLength, Min, ValidateIf, ValidateNested, } from 'class-validator'; +import { CreateCaCertificateDto } from 'src/modules/certificate/dto/create.ca-certificate.dto'; +import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certificate.dto'; +import { Expose, Type } from 'class-transformer'; +import { caCertTransformer } from 'src/modules/certificate/transformers/ca-cert.transformer'; +import { Default } from 'src/common/decorators'; +import { CreateClientCertificateDto } from 'src/modules/certificate/dto/create.client-certificate.dto'; +import { clientCertTransformer } from 'src/modules/certificate/transformers/client-cert.transformer'; +import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; +import { SentinelMaster } from 'src/modules/redis-sentinel/models/sentinel-master'; +import { CreateDatabaseDto } from 'src/modules/database/dto/create.database.dto'; -export class UpdateDatabaseDto extends PartialType(CreateDatabaseDto) { +export class UpdateDatabaseDto extends CreateDatabaseDto { @ValidateIf((object, value) => value !== undefined) @IsString({ always: true }) @MaxLength(500) @@ -17,4 +27,82 @@ export class UpdateDatabaseDto extends PartialType(CreateDatabaseDto) { @ValidateIf((object, value) => value !== undefined) @IsInt({ always: true }) port: number; + + @ApiPropertyOptional({ + description: 'Logical database number.', + type: Number, + }) + @IsInt() + @Min(0) + @IsOptional() + @Default(null) + db?: number; + + @ApiPropertyOptional({ + description: 'Use TLS to connect.', + type: Boolean, + }) + @IsBoolean() + @IsOptional() + @Default(false) + tls?: boolean; + + @ApiPropertyOptional({ + description: 'SNI servername', + type: String, + }) + @IsString() + @IsNotEmpty() + @IsOptional() + @Default(null) + tlsServername?: string; + + @ApiPropertyOptional({ + description: 'The certificate returned by the server needs to be verified.', + type: Boolean, + default: false, + }) + @IsOptional() + @IsBoolean({ always: true }) + @Default(false) + verifyServerCert?: boolean; + + @ApiPropertyOptional({ + description: 'CA Certificate', + oneOf: [ + { $ref: getSchemaPath(CreateCaCertificateDto) }, + { $ref: getSchemaPath(UseCaCertificateDto) }, + ], + }) + @IsOptional() + @IsNotEmptyObject() + @Type(caCertTransformer) + @ValidateNested() + @Default(null) + caCert?: CreateCaCertificateDto | UseCaCertificateDto; + + @ApiPropertyOptional({ + description: 'Client Certificate', + oneOf: [ + { $ref: getSchemaPath(CreateClientCertificateDto) }, + { $ref: getSchemaPath(UseCaCertificateDto) }, + ], + }) + @IsOptional() + @IsNotEmptyObject() + @Type(clientCertTransformer) + @ValidateNested() + @Default(null) + clientCert?: CreateClientCertificateDto | UseClientCertificateDto; + + @ApiPropertyOptional({ + description: 'Redis OSS Sentinel master group.', + type: SentinelMaster, + }) + @IsOptional() + @IsNotEmptyObject() + @Type(() => SentinelMaster) + @ValidateNested() + @Default(null) + sentinelMaster?: SentinelMaster; } diff --git a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts new file mode 100644 index 0000000000..2414aada9e --- /dev/null +++ b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts @@ -0,0 +1,774 @@ +import { + expect, + describe, + deps, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + requirements, + getMainCheckFn, + _, it, validateApiCall, after +} from '../deps'; +import { Joi } from '../../helpers/test'; +import { databaseSchema } from './constants'; + +const { request, server, localDb, constants, rte } = deps; + +const endpoint = (id = constants.TEST_INSTANCE_ID) => request(server).patch(`/${constants.API.DATABASES}/${id}`); + +// input data schema +const dataSchema = Joi.object({ + name: Joi.string().max(500), + host: Joi.string(), + port: Joi.number().integer(), + db: Joi.number().integer().allow(null), + username: Joi.string().allow(null), + password: Joi.string().allow(null), + tls: Joi.boolean().allow(null), + tlsServername: Joi.string().allow(null), + verifyServerCert: Joi.boolean().allow(null), +}).messages({ + 'any.required': '{#label} should not be empty', +}).strict(true); + +const validInputData = { + name: constants.getRandomString(), + host: constants.getRandomString(), + port: 111, +}; + +const baseDatabaseData = { + name: 'someName', + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: constants.TEST_REDIS_USER || undefined, + password: constants.TEST_REDIS_PASSWORD || undefined, +} + +const responseSchema = databaseSchema.required().strict(true); + +const mainCheckFn = getMainCheckFn(endpoint); + +let oldDatabase; +let newDatabase; +describe(`PUT /databases/:id`, () => { + beforeEach(async () => await localDb.createDatabaseInstances()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + + [ + { + name: 'should deprecate to pass both cert id and other cert fields', + data: { + ...validInputData, + caCert: { + id: 'id', + name: 'ca', + certificate: 'ca_certificate', + }, + clientCert: { + id: 'id', + name: 'client', + certificate: 'client_cert', + key: 'client_key', + }, + }, + statusCode: 400, + responseBody: { + statusCode: 400, + error: 'Bad Request', + }, + checkFn: ({ body }) => { + expect(body.message).to.contain('caCert.property name should not exist'); + expect(body.message).to.contain('caCert.property certificate should not exist'); + expect(body.message).to.contain('clientCert.property name should not exist'); + expect(body.message).to.contain('clientCert.property certificate should not exist'); + expect(body.message).to.contain('clientCert.property key should not exist'); + }, + }, + ].map(mainCheckFn); + }); + describe('Common', () => { + const newName = constants.getRandomString(); + + [ + { + name: 'Should change name (only) for existing database', + data: { + name: newName, + }, + responseSchema, + before: async () => { + oldDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID); + expect(oldDatabase.name).to.not.eq(newName); + }, + responseBody: { + name: newName, + }, + after: async () => { + newDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID); + expect(newDatabase.name).to.eq(newName); + }, + }, + { + name: 'Should return 503 error if incorrect connection data provided', + data: { + name: 'new name', + port: 1111, + }, + statusCode: 503, + responseBody: { + statusCode: 503, + // message: `Could not connect to ${constants.TEST_REDIS_HOST}:1111, please check the connection details.`, + // todo: verify error handling because right now messages are different + error: 'Service Unavailable' + }, + after: async () => { + // check that instance wasn't changed + const newDb = await localDb.getInstanceById(constants.TEST_INSTANCE_ID); + expect(newDb.name).to.not.eql('new name'); + expect(newDb.port).to.eql(constants.TEST_REDIS_PORT); + }, + }, + { + name: 'Should return Not Found Error', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + name: 'new name', + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }, + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + }, + ].map(mainCheckFn); + }); + describe('STANDALONE', () => { + requirements('rte.type=STANDALONE'); + describe('NO AUTH', function () { + requirements('!rte.tls', '!rte.pass'); + + [ + { + name: 'Should change host and port and recalculate data such as (provider, modules, etc...)', + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_3), + data: { + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }, + responseSchema, + before: async () => { + oldDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID_3); + expect(oldDatabase.name).to.eq(constants.TEST_INSTANCE_NAME_3); + expect(oldDatabase.modules).to.eq('[]'); + expect(oldDatabase.host).to.not.eq(constants.TEST_REDIS_HOST) + expect(oldDatabase.port).to.not.eq(constants.TEST_REDIS_PORT) + }, + responseBody: { + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: null, + password: null, + connectionType: constants.STANDALONE, + tls: false, + verifyServerCert: false, + tlsServername: null, + }, + after: async () => { + newDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID_3); + expect(newDatabase).to.contain({ + ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new']), + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + }); + }, + }, + ].map(mainCheckFn); + + describe('Enterprise', () => { + requirements('rte.re'); + it('Should throw an error if db index specified', async () => { + await validateApiCall({ + endpoint, + statusCode: 400, + data: { + db: constants.TEST_REDIS_DB_INDEX + }, + }); + }); + }); + describe('Oss', () => { + requirements('!rte.re'); + it('Update standalone with particular db index', async () => { + let addedId; + const dbName = constants.getRandomString(); + const cliUuid = constants.getRandomString(); + const browserKeyName = constants.getRandomString(); + const cliKeyName = constants.getRandomString(); + + await validateApiCall({ + endpoint, + data: { + db: constants.TEST_REDIS_DB_INDEX, + }, + responseSchema, + responseBody: { + db: constants.TEST_REDIS_DB_INDEX, + }, + checkFn: ({ body }) => { + addedId = body.id; + } + }); + + // Create string using Browser API to particular db index + await validateApiCall({ + endpoint: () => request(server).post(`/${constants.API.DATABASES}/${addedId}/string`), + statusCode: 201, + data: { + keyName: browserKeyName, + value: 'somevalue' + }, + }); + + // Create client for CLI + await validateApiCall({ + endpoint: () => request(server).patch(`/${constants.API.DATABASES}/${addedId}/cli/${cliUuid}`), + statusCode: 200, + }); + + // Create string using CLI API to 0 db index + await validateApiCall({ + endpoint: () => request(server).post(`/${constants.API.DATABASES}/${addedId}/cli/${cliUuid}/send-command`), + statusCode: 200, + data: { + command: `set ${cliKeyName} somevalue`, + }, + }); + + + // check data created by db index + await rte.data.executeCommand('select', `${constants.TEST_REDIS_DB_INDEX}`); + expect(await rte.data.executeCommand('exists', cliKeyName)).to.eql(1) + expect(await rte.data.executeCommand('exists', browserKeyName)).to.eql(1) + + // check data created by db index + await rte.data.executeCommand('select', '0'); + expect(await rte.data.executeCommand('exists', cliKeyName)).to.eql(0) + expect(await rte.data.executeCommand('exists', browserKeyName)).to.eql(0) + + // switch back to db index 0 + await validateApiCall({ + endpoint, + data: { + db: 0, + }, + responseSchema, + responseBody: { + db: 0, + }, + checkFn: ({ body }) => { + addedId = body.id; + } + }); + }); + }); + }); + describe('PASS', function () { + requirements('!rte.tls', 'rte.pass'); + it('Update standalone with password', async () => { + const dbName = constants.getRandomString(); + + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_3), + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + password: constants.TEST_REDIS_PASSWORD, + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + username: null, + password: constants.TEST_REDIS_PASSWORD, + connectionType: constants.STANDALONE, + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + // todo: cover connection error for incorrect username/password + }); + describe('TLS CA', function () { + requirements('rte.tls', '!rte.tlsAuth'); + it('update standalone instance using tls without CA verify', async () => { + const dbName = constants.getRandomString(); + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + data: { + ...baseDatabaseData, + name: dbName, + tls: true, + verifyServerCert: false, + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: true, + verifyServerCert: false, + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + it('Update standalone instance using tls and verify and create CA certificate (new)', async () => { + const dbName = constants.getRandomString(); + const newCaName = constants.getRandomString(); + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + data: { + ...baseDatabaseData, + name: dbName, + tls: true, + verifyServerCert: true, + caCert: { + name: newCaName, + certificate: constants.TEST_REDIS_TLS_CA, + }, + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: true, + verifyServerCert: true, + tlsServername: null, + }, + checkFn: async ({ body }) => { + expect(body.caCert.id).to.be.a('string'); + expect(body.caCert.name).to.eq(newCaName); + expect(body.caCert.certificate).to.be.undefined; + + const ca: any = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)) + .findOneBy({ id: body.caCert.id }); + + expect(ca.certificate).to.eql(localDb.encryptData(constants.TEST_REDIS_TLS_CA)); + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + it('Should throw an error without CA cert when cert validation enabled', async () => { + const dbName = constants.getRandomString(); + + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + statusCode: 400, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: true, + verifyServerCert: true, + caCert: null, + }, + responseBody: { + statusCode: 400, + // todo: verify error handling because right now messages are different + // message: 'Could not connect to', + error: 'Bad Request' + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + }); + it('Should throw an error with invalid CA cert', async () => { + const dbName = constants.getRandomString(); + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_2), + statusCode: 400, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + tls: true, + verifyServerCert: true, + caCert: { + name: dbName, + certificate: 'invalid' + }, + }, + responseBody: { + statusCode: 400, + // todo: verify error handling because right now messages are different + // message: 'Could not connect to', + error: 'Bad Request' + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + }); + }); + describe('TLS AUTH', function () { + requirements('rte.tls', 'rte.tlsAuth'); + + let existingCACertId, existingClientCertId, existingCACertName, existingClientCertName; + + beforeEach(localDb.initAgreements); + after(localDb.initAgreements); + + // should be first test to not break other tests + it('Update standalone instance and verify users certs (new certificates !do not encrypt)', async () => { + await localDb.setAgreements({ + encryption: false, + }); + + const dbName = constants.getRandomString(); + const newCaName = constants.getRandomString(); + const newClientCertName = constants.getRandomString(); + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + await validateApiCall({ + endpoint, + data: { + ...baseDatabaseData, + name: dbName, + tls: true, + verifyServerCert: true, + caCert: { + name: newCaName, + certificate: constants.TEST_REDIS_TLS_CA, + }, + clientCert: { + name: newClientCertName, + certificate: constants.TEST_USER_TLS_CERT, + key: constants.TEST_USER_TLS_KEY, + }, + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: true, + verifyServerCert: true, + tlsServername: null, + }, + checkFn: async ({ body }) => { + expect(body.caCert.id).to.be.a('string'); + expect(body.caCert.name).to.eq(newCaName); + expect(body.caCert.certificate).to.be.undefined; + + expect(body.clientCert.id).to.be.a('string'); + expect(body.clientCert.name).to.deep.eq(newClientCertName); + expect(body.clientCert.certificate).to.be.undefined; + expect(body.clientCert.key).to.be.undefined; + + const ca: any = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)) + .findOneBy({ id: body.caCert.id }); + + expect(ca.certificate).to.eql(constants.TEST_REDIS_TLS_CA); + + const clientPair: any = await (await localDb.getRepository(localDb.repositories.CLIENT_CERT_REPOSITORY)) + .findOneBy({ id: body.clientCert.id }); + + expect(clientPair.certificate).to.eql(constants.TEST_USER_TLS_CERT); + expect(clientPair.key).to.eql(constants.TEST_USER_TLS_KEY); + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + it('Update standalone instance and verify users certs (new certificates)', async () => { + const dbName = constants.getRandomString(); + const newCaName = existingCACertName = constants.getRandomString(); + const newClientCertName = existingClientCertName = constants.getRandomString(); + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + const { body } = await validateApiCall({ + endpoint, + data: { + ...baseDatabaseData, + name: dbName, + tls: true, + verifyServerCert: true, + caCert: { + name: newCaName, + certificate: constants.TEST_REDIS_TLS_CA, + }, + clientCert: { + name: newClientCertName, + certificate: constants.TEST_USER_TLS_CERT, + key: constants.TEST_USER_TLS_KEY, + }, + }, + responseSchema, + responseBody: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: true, + verifyServerCert: true, + tlsServername: null, + }, + checkFn: async ({ body }) => { + expect(body.caCert.id).to.be.a('string'); + expect(body.caCert.name).to.eq(newCaName); + expect(body.caCert.certificate).to.be.undefined; + + expect(body.clientCert.id).to.be.a('string'); + expect(body.clientCert.name).to.deep.eq(newClientCertName); + expect(body.clientCert.certificate).to.be.undefined; + expect(body.clientCert.key).to.be.undefined; + + const ca: any = await (await localDb.getRepository(localDb.repositories.CA_CERT_REPOSITORY)) + .findOneBy({ id: body.caCert.id }); + + expect(ca.certificate).to.eql(localDb.encryptData(constants.TEST_REDIS_TLS_CA)); + + const clientPair: any = await (await localDb.getRepository(localDb.repositories.CLIENT_CERT_REPOSITORY)) + .findOneBy({ id: body.clientCert.id }); + + expect(clientPair.certificate).to.eql(localDb.encryptData(constants.TEST_USER_TLS_CERT)); + expect(clientPair.key).to.eql(localDb.encryptData(constants.TEST_USER_TLS_KEY)); + }, + }); + + // remember certificates ids + existingCACertId = body.caCert.id; + existingClientCertId = body.clientCert.id; + + expect(await localDb.getInstanceByName(dbName)).to.be.an('object'); + }); + it('Should update standalone instance with existing certificates', async () => { + const dbName = constants.getRandomString(); + + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + await validateApiCall({ + endpoint, + data: { + tls: true, + verifyServerCert: true, + caCert: { + id: existingCACertId, + }, + clientCert: { + id: existingClientCertId, + }, + }, + responseSchema, + responseBody: { + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + connectionType: constants.STANDALONE, + tls: true, + verifyServerCert: true, + tlsServername: null, + }, + checkFn: async ({ body }) => { + expect(body.caCert.name).to.be.a('string'); + expect(body.caCert.id).to.eq(existingCACertId); + expect(body.caCert.certificate).to.be.undefined; + + expect(body.clientCert.id).to.deep.eq(existingClientCertId); + expect(body.clientCert.name).to.be.a('string'); + expect(body.clientCert.certificate).to.be.undefined; + expect(body.clientCert.key).to.be.undefined; + }, + }); + }); + it('Should throw an error if try to create client certificate with existing name', async () => { + const dbName = constants.getRandomString(); + const newCaName = constants.getRandomString(); + + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + await validateApiCall({ + endpoint, + statusCode: 400, + data: { + ...baseDatabaseData, + name: dbName, + tls: true, + verifyServerCert: true, + caCert: { + name: newCaName, + certificate: constants.TEST_REDIS_TLS_CA, + }, + clientCert: { + name: existingClientCertName, + certificate: constants.TEST_USER_TLS_CERT, + key: constants.TEST_USER_TLS_KEY, + }, + }, + responseBody: { + error: 'Bad Request', + message: 'This client certificate name is already in use.', + statusCode: 400, + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + }); + it('Should throw an error if try to create ca certificate with existing name', async () => { + const dbName = constants.getRandomString(); + const newClientName = constants.getRandomString(); + + // preconditions + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + + await validateApiCall({ + endpoint, + statusCode: 400, + data: { + ...baseDatabaseData, + name: dbName, + tls: true, + verifyServerCert: true, + caCert: { + name: existingCACertName, + certificate: constants.TEST_REDIS_TLS_CA, + }, + clientCert: { + name: newClientName, + certificate: constants.TEST_USER_TLS_CERT, + key: constants.TEST_USER_TLS_KEY, + }, + }, + responseBody: { + error: 'Bad Request', + message: 'This ca certificate name is already in use.', + statusCode: 400, + }, + }); + + expect(await localDb.getInstanceByName(dbName)).to.eql(null); + }); + }); + }); + describe('CLUSTER', () => { + requirements('rte.type=CLUSTER'); + describe('NO AUTH', function () { + requirements('!rte.tls', '!rte.pass'); + it('Update instance without pass', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_3), + data: { + ...baseDatabaseData, + name: dbName, + }, + responseSchema, + responseBody: { + name: dbName, + port: constants.TEST_REDIS_PORT, + connectionType: constants.CLUSTER, + nodes: rte.env.nodes, + }, + }); + }); + it('Should throw an error if db index specified', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_3), + statusCode: 400, + data: { + name: dbName, + host: constants.TEST_REDIS_HOST, + port: constants.TEST_REDIS_PORT, + db: constants.TEST_REDIS_DB_INDEX + }, + }); + }); + }); + describe('TLS CA', function () { + requirements('rte.tls', '!rte.tlsAuth'); + it('Should create instance without CA tls', async () => { + const dbName = constants.getRandomString(); + + await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_3), + data: { + ...baseDatabaseData, + name: dbName, + tls: true, + verifyServerCert: false, + }, + responseSchema, + responseBody: { + name: dbName, + connectionType: constants.CLUSTER, + tls: true, + nodes: rte.env.nodes, + verifyServerCert: false, + }, + }); + }); + it('Should create instance tls and create new CA cert', async () => { + const dbName = constants.getRandomString(); + + const { body } = await validateApiCall({ + endpoint: () => endpoint(constants.TEST_INSTANCE_ID_3), + data: { + ...baseDatabaseData, + name: dbName, + tls: true, + verifyServerCert: true, + caCert: { + name: constants.getRandomString(), + certificate: constants.TEST_REDIS_TLS_CA, + }, + }, + responseSchema, + responseBody: { + name: dbName, + port: constants.TEST_REDIS_PORT, + connectionType: constants.CLUSTER, + nodes: rte.env.nodes, + tls: true, + verifyServerCert: true, + }, + }); + }); + // todo: Should throw an error without CA cert when cert validation enabled + // todo: Should throw an error with invalid CA cert + }); + }); +}); diff --git a/redisinsight/ui/src/slices/instances/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts index 7fc52c2789..36f0bf7d9e 100644 --- a/redisinsight/ui/src/slices/instances/instances.ts +++ b/redisinsight/ui/src/slices/instances/instances.ts @@ -486,7 +486,7 @@ export function changeInstanceAliasAction( const { CancelToken } = axios sourceInstance = CancelToken.source() - const { status } = await apiService.put( + const { status } = await apiService.patch( `${ApiEndpoints.DATABASES}/${id}`, { name }, { cancelToken: sourceInstance.token } From c7e5106f32e6893f5523125bd8f546b229b1aff6 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 20 Dec 2022 21:45:36 +0200 Subject: [PATCH 57/57] fix tests --- .../api/database/PUT-databases-id.test.ts | 20 ------------------- redisinsight/api/test/helpers/local-db.ts | 3 ++- .../slices/tests/instances/instances.spec.ts | 4 ++-- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/redisinsight/api/test/api/database/PUT-databases-id.test.ts b/redisinsight/api/test/api/database/PUT-databases-id.test.ts index 95a09152cf..d53231eafd 100644 --- a/redisinsight/api/test/api/database/PUT-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PUT-databases-id.test.ts @@ -91,27 +91,7 @@ describe(`PUT /databases/:id`, () => { ].map(mainCheckFn); }); describe('Common', () => { - const newName = constants.getRandomString(); - [ - { - name: 'Should change name (only) for existing database', - data: { - name: newName, - }, - responseSchema, - before: async () => { - oldDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID); - expect(oldDatabase.name).to.not.eq(newName); - }, - responseBody: { - name: newName, - }, - after: async () => { - newDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID); - expect(newDatabase.name).to.eq(newName); - }, - }, { name: 'Should return 503 error if incorrect connection data provided', data: { diff --git a/redisinsight/api/test/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts index 0a7ae6b936..c7c24bddfe 100644 --- a/redisinsight/api/test/helpers/local-db.ts +++ b/redisinsight/api/test/helpers/local-db.ts @@ -292,7 +292,8 @@ export const createDatabaseInstances = async () => { host: 'localhost', port: 3679, connectionType: 'STANDALONE', - ...instance + ...instance, + modules: '[]', }); } } diff --git a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts index 24a5cff3dc..769c3d1f36 100644 --- a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts @@ -1023,7 +1023,7 @@ describe('instances slice', () => { newName: 'newAlias', } const responsePayload = { status: 200, data } - apiService.put = jest.fn().mockResolvedValue(responsePayload) + apiService.patch = jest.fn().mockResolvedValue(responsePayload) // Act await store.dispatch( @@ -1047,7 +1047,7 @@ describe('instances slice', () => { data: { message: errorMessage }, }, } - apiService.put = jest.fn().mockRejectedValue(responsePayload) + apiService.patch = jest.fn().mockRejectedValue(responsePayload) // Act await store.dispatch(