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/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__/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 new file mode 100644 index 0000000000..fc8c44777a --- /dev/null +++ b/redisinsight/api/src/__mocks__/database-import.ts @@ -0,0 +1,89 @@ +import { DatabaseImportResponse, DatabaseImportStatus } from 'src/modules/database-import/dto/database-import.response'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +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(mockSentinelDatabaseWithTlsAuth); + +export const mockDatabaseImportFile = { + originalname: 'filename.json', + mimetype: 'application/json', + size: 1, + 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, + 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(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, + })), + fail: [ + new ValidationException('Bad request'), + new BadRequestException(), + new ForbiddenException(), + ].map((error, index) => ({ + ...mockDatabaseImportResultFail, + index, + errors: [error], + })), +}); + +export const mockDatabaseImportPartialAnalyticsPayload = { + partially: mockDatabaseImportResponse.partial.length, + errors: ['InvalidCaCertificateBodyException', 'InvalidCertificateNameException'], +}; + +export const mockDatabaseImportFailedAnalyticsPayload = { + failed: mockDatabaseImportResponse.fail.length, + errors: ['ValidationException', 'BadRequestException', 'ForbiddenException'], +}; + +export const mockDatabaseImportSucceededAnalyticsPayload = { + succeed: mockDatabaseImportResponse.success.length, +}; + +export const mockDatabaseImportAnalytics = jest.fn(() => ({ + sendImportResults: jest.fn(), + sendImportFailed: jest.fn(), +})); + +export const mockCertificateImportService = jest.fn(() => ({ + processCaCertificate: jest.fn().mockResolvedValue(mockCaCertificate), + processClientCertificate: jest.fn().mockResolvedValue(mockClientCertificate), +})); diff --git a/redisinsight/api/src/__mocks__/databases.ts b/redisinsight/api/src/__mocks__/databases.ts index 409a9a06ee..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'; @@ -25,6 +26,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(), { @@ -113,6 +115,17 @@ 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 = { + session: undefined, + databaseId: mockDatabase.id, + context: ClientContext.Common, +}; + export const mockDatabaseOverview: DatabaseOverview = { version: '6.2.4', usedMemory: 1, 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/app.module.ts b/redisinsight/api/src/app.module.ts index c7d9ca717d..5196f0f3a8 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 { DummyAuthMiddleware } from 'src/common/middlewares/dummy-auth.middleware'; import { BrowserModule } from './modules/browser/browser.module'; import { RedisEnterpriseModule } from './modules/redis-enterprise/redis-enterprise.module'; @@ -53,6 +54,7 @@ const PATH_CONFIG = config.get('dir_path'); BulkActionsModule, ClusterMonitorModule, DatabaseAnalysisModule, + DatabaseImportModule, ...(SERVER_CONFIG.staticContent ? [ ServeStaticModule.forRoot({ 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/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/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index 75385fbef0..15b36f1531 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -14,6 +14,12 @@ 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', + DatabaseImportPartiallySucceeded = 'CONFIG_DATABASES_REDIS_IMPORT_PARTIALLY_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/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/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.analytics.spec.ts b/redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts new file mode 100644 index 0000000000..a2554d742e --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.analytics.spec.ts @@ -0,0 +1,102 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + mockDatabaseImportFailedAnalyticsPayload, mockDatabaseImportPartialAnalyticsPayload, + 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, + ); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 3, + TelemetryEvents.DatabaseImportPartiallySucceeded, + mockDatabaseImportPartialAnalyticsPayload, + ); + }); + }); + + 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.analytics.ts b/redisinsight/api/src/modules/database-import/database-import.analytics.ts new file mode 100644 index 0000000000..b343b92df6 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.analytics.ts @@ -0,0 +1,65 @@ +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, DatabaseImportResult } 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?.length) { + this.sendEvent( + TelemetryEvents.DatabaseImportSucceeded, + { + succeed: importResult.success.length, + }, + ); + } + + if (importResult.fail?.length) { + this.sendEvent( + TelemetryEvents.DatabaseImportFailed, + { + failed: importResult.fail.length, + errors: DatabaseImportAnalytics.getUniqueErrorNamesFromResults(importResult.fail), + }, + ); + } + + if (importResult.partial?.length) { + this.sendEvent( + TelemetryEvents.DatabaseImportPartiallySucceeded, + { + partially: importResult.partial.length, + errors: DatabaseImportAnalytics.getUniqueErrorNamesFromResults(importResult.partial), + }, + ); + } + } + + sendImportFailed(e: Error): void { + this.sendEvent( + TelemetryEvents.DatabaseImportParseFailed, + { + error: e?.constructor?.name || 'UncaughtError', + }, + ); + } + + 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.controller.ts b/redisinsight/api/src/modules/database-import/database-import.controller.ts new file mode 100644 index 0000000000..eb0d02780e --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.controller.ts @@ -0,0 +1,41 @@ +import { + ClassSerializerInterceptor, + Controller, HttpCode, 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 })) +@UseInterceptors(ClassSerializerInterceptor) +@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', + }, + }, + }, + }) + @HttpCode(200) + @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..b4814589dc --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.module.ts @@ -0,0 +1,15 @@ +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, + ], +}) +export class DatabaseImportModule {} 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..45a4957e33 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.service.spec.ts @@ -0,0 +1,251 @@ +import { pick } from 'lodash'; +import { DatabaseImportService } from 'src/modules/database-import/database-import.service'; +import { + mockCertificateImportService, + 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 { + InvalidCaCertificateBodyException, InvalidCertificateNameException, InvalidClientCertificateBodyException, + NoDatabaseImportFileProvidedException, + SizeLimitExceededDatabaseImportFileException, + 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; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseImportService, + { + provide: DatabaseRepository, + useFactory: jest.fn(() => ({ + create: jest.fn().mockResolvedValue(mockDatabase), + })), + }, + { + provide: CertificateImportService, + useFactory: mockCertificateImportService, + }, + { + provide: DatabaseImportAnalytics, + useFactory: mockDatabaseImportAnalytics, + }, + ], + }).compile(); + + 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'); + }); + + describe('importDatabase', () => { + beforeEach(() => { + 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 () => { + const response = await service.import(mockDatabaseImportFile); + + expect(response).toEqual(mockDatabaseImportResponse); + 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, + }); + 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 fail 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, + }, 0); + + expect(databaseRepository.create).toHaveBeenCalledWith({ + ...pick(mockDatabase, ['host', 'port', 'name', 'connectionType']), + new: true, + }); + }); + it('should create standalone with created name', async () => { + await service['createDatabase']({ + ...mockDatabase, + name: undefined, + }, 0); + + expect(databaseRepository.create).toHaveBeenCalledWith({ + ...pick(mockDatabase, ['host', 'port', 'name', 'connectionType']), + name: `${mockDatabase.host}:${mockDatabase.port}`, + new: true, + }); + }); + it('should create cluster database', async () => { + await service['createDatabase']({ + ...mockDatabase, + connectionType: undefined, + cluster: true, + }, 0); + + expect(databaseRepository.create).toHaveBeenCalledWith({ + ...pick(mockDatabase, ['host', 'port', 'name']), + connectionType: ConnectionType.CLUSTER, + new: true, + }); + }); + }); + + 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 new file mode 100644 index 0000000000..78e0ac1df6 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/database-import.service.ts @@ -0,0 +1,324 @@ +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, + 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, + 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 { + private logger = new Logger('DatabaseImportService'); + + private validator = new Validator(); + + private fieldsMapSchema: Array<[string, string[]]> = [ + ['name', ['name', 'connectionName']], + ['username', ['username']], + ['password', ['password', 'auth']], + ['host', ['host']], + ['port', ['port']], + ['db', ['db']], + ['isCluster', ['cluster']], + ['type', ['type']], + ['connectionType', ['connectionType']], + ['tls', ['tls', 'ssl']], + ['tlsServername', ['tlsServername']], + ['tlsCaName', ['caCert.name']], + ['tlsCaCert', ['caCert.certificate', 'caCert', 'sslOptions.ca', 'ssl_ca_cert_path']], + ['tlsClientName', ['clientCert.name']], + ['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( + private readonly certificateImportService: CertificateImportService, + private readonly databaseRepository: DatabaseRepository, + private readonly analytics: DatabaseImportAnalytics, + ) {} + + /** + * Import databases from the file + * @param file + */ + public async import(file): Promise { + 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'); + } + + 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}`); + } + + let response = { + total: items.length, + success: [], + partial: [], + fail: [], + }; + + // 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) => { + 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 + } + })), Promise.resolve()); + + response = plainToClass(DatabaseImportResponse, response); + + this.analytics.sendImportResults(response); + + return response; + } catch (e) { + this.logger.warn(`Unable to import databases: ${e?.constructor?.name || 'UncaughtError'}`, e); + + this.analytics.sendImportFailed(e); + + throw e; + } + } + + /** + * 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, index: number): Promise { + try { + let status = DatabaseImportStatus.Success; + const errors = []; + const data: any = {}; + + // set this is a new connection + data.new = true; + + 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}`; + } + + 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) { + 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 + Object.keys(data) + .reduce((acc, key) => { + acc[key] = data[key] === '' ? null : data[key]; + return acc; + }, {}), + { + groups: ['security'], + }, + ); + + await this.validator.validateOrReject(dto, { + whitelist: true, + }); + + const database = classToClass(Database, dto); + + await this.databaseRepository.create(database); + + return { + index, + status, + host: database.host, + port: database.port, + errors: errors?.length ? errors : undefined, + }; + } catch (e) { + let errors = [e]; + if (isArray(e)) { + errors = e; + } + + 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)) { + return new InternalServerErrorException(error?.message); + } + + return 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, + errors, + }; + } + } + + /** + * 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) { + return (data.connectionType in ConnectionType) + ? ConnectionType[data.connectionType] + : 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 + */ + static parseFile(file): any { + const data = file?.buffer?.toString(); + + let databases = DatabaseImportService.parseJson(data); + + if (!databases) { + 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..79f29f659b --- /dev/null +++ b/redisinsight/api/src/modules/database-import/dto/database-import.response.ts @@ -0,0 +1,98 @@ +import { isString, isNumber } 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() + @Transform((v) => (isString(v) ? v : undefined), { toPlainOnly: true }) + host?: string; + + @ApiPropertyOptional({ + description: 'Database port', + type: Number, + }) + @Expose() + @Transform((v) => (isNumber(v) ? v : undefined), { toPlainOnly: true }) + port?: number; + + @ApiPropertyOptional({ + description: 'Error message if any', + type: String, + }) + @Expose() + @Transform((e) => { + if (!e) { + return undefined; + } + + return e.map((error) => { + if (error?.response) { + return error.response; + } + + return { + statusCode: 500, + message: error?.message || 'Unhandled Error', + error: 'Unhandled Error', + }; + }); + }, { toPlainOnly: true }) + errors?: Error[]; +} + +export class DatabaseImportResponse { + @ApiProperty({ + description: 'Total elements processed from the import file', + type: Number, + }) + @Expose() + total: number; + + @ApiProperty({ + description: 'List of successfully imported database', + type: DatabaseImportResult, + }) + @Expose() + @Type(() => DatabaseImportResult) + success: DatabaseImportResult[]; + + @ApiProperty({ + description: 'List of partially imported database', + type: DatabaseImportResult, + }) + @Expose() + @Type(() => DatabaseImportResult) + partial: DatabaseImportResult[]; + + @ApiProperty({ + description: 'List of databases failed to import', + type: DatabaseImportResult, + }) + @Expose() + @Type(() => DatabaseImportResult) + fail: DatabaseImportResult[]; +} 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..eb5b5b26bc --- /dev/null +++ b/redisinsight/api/src/modules/database-import/dto/import.database.dto.ts @@ -0,0 +1,47 @@ +import { ApiPropertyOptional, getSchemaPath, PickType } from '@nestjs/swagger'; +import { Database } from 'src/modules/database/models/database'; +import { Expose, Type } from 'class-transformer'; +import { + 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', 'tls', 'verifyServerCert', 'sentinelMaster', 'nodes', + 'new', +] as const) { + @Expose() + @IsNotEmpty() + @IsInt({ always: true }) + @Type(() => Number) + @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 new file mode 100644 index 0000000000..2ba9fdfb68 --- /dev/null +++ b/redisinsight/api/src/modules/database-import/exceptions/index.ts @@ -0,0 +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); + } +} 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/src/modules/database/database-connection.service.ts b/redisinsight/api/src/modules/database/database-connection.service.ts index 5e69d845fc..e5a49a9f87 100644 --- a/redisinsight/api/src/modules/database/database-connection.service.ts +++ b/redisinsight/api/src/modules/database/database-connection.service.ts @@ -6,6 +6,8 @@ import { DatabaseAnalytics } from 'src/modules/database/database.analytics'; import { RedisService } from 'src/modules/redis/redis.service'; 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'; import { ClientMetadata } from 'src/common/models'; @Injectable() @@ -28,12 +30,29 @@ export class DatabaseConnectionService { const client = await this.getOrCreateClient(clientMetadata); // 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 - await this.repository.update(clientMetadata.databaseId, { + const toUpdate: Partial = { + new: false, 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(clientMetadata.databaseId, toUpdate); this.logger.log(`Succeed to connect to database ${clientMetadata.databaseId}`); } @@ -75,11 +94,29 @@ export class DatabaseConnectionService { const connectionName = generateRedisConnectionName(clientMetadata.context, clientMetadata.databaseId); try { - return await this.redisService.connectToDatabaseInstance( + const client = await this.redisService.connectToDatabaseInstance( database, clientMetadata.context, 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/database.controller.ts b/redisinsight/api/src/modules/database/database.controller.ts index 99cbf7a2e0..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,8 +26,9 @@ 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 Instances') +@ApiTags('Database') @Controller('databases') export class DatabaseController { constructor( @@ -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.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 diff --git a/redisinsight/api/src/modules/database/database.service.ts b/redisinsight/api/src/modules/database/database.service.ts index 8c29e28050..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 { @@ -88,9 +89,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 { @@ -117,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/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index 179df10fe0..6c193c605b 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') @@ -157,4 +158,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 27731772b7..5e6b4c029c 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({ @@ -141,6 +142,8 @@ export class Database { type: Endpoint, isArray: true, }) + @IsOptional() + @Type(() => Endpoint) @Expose() nodes?: Endpoint[]; @@ -202,4 +205,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 c79e489393..24723f3d8a 100644 --- a/redisinsight/api/src/modules/database/providers/database.factory.ts +++ b/redisinsight/api/src/modules/database/providers/database.factory.ts @@ -46,6 +46,7 @@ export class DatabaseFactory { model.modules = await this.databaseInfoProvider.determineDatabaseModules(client); model.lastConnection = new Date(); + 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/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 8807e324ff..47736d98a8 100644 --- a/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts +++ b/redisinsight/api/src/modules/redis-sentinel/redis-sentinel.service.ts @@ -11,6 +11,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 { @@ -19,6 +20,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, ) {} @@ -112,7 +114,8 @@ export class RedisSentinelService { this.logger.log('Connection and getting sentinel masters.'); let result: SentinelMaster[]; try { - const client = await this.redisService.createStandaloneClient(dto, ClientContext.Common, false); + const database = await this.databaseFactory.createStandaloneDatabaseModel(dto); + const client = await this.redisService.createStandaloneClient(database, ClientContext.Common, false); result = await this.databaseInfoProvider.determineSentinelMasterGroups(client); this.redisSentinelAnalytics.sendGetSentinelMastersSucceedEvent(result); diff --git a/redisinsight/api/src/modules/redis/redis.service.ts b/redisinsight/api/src/modules/redis/redis.service.ts index c3ce2f8901..de94a88e79 100644 --- a/redisinsight/api/src/modules/redis/redis.service.ts +++ b/redisinsight/api/src/modules/redis/redis.service.ts @@ -72,14 +72,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, @@ -180,12 +183,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: ClientContext, 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'; 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..d7c6f49fc1 --- /dev/null +++ b/redisinsight/api/test/api/database-import/POST-databases-import.test.ts @@ -0,0 +1,1604 @@ +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().allow(true).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 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 = { + 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 }) => { + 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: [], + 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].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'); + } + if (body.fail[0].port) { + expect(body.fail[0].port).to.be.a('number'); + } + } + } + }) + .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: 'No Database Import File Provided', + }, + }, + { + 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: 'Invalid Database Import File', + }, + }, + { + 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: 'Unable To Parse Database Import File', + }, + }, + { + 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: 'Unable To Parse Database Import File', + }, + }, + { + 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: 'Unable To Parse Database Import File', + }, + }, + ].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 (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([ + { + ...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 (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 (format 3)', async () => { + const name = constants.getRandomString(); + + 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('Oss', () => { + requirements('!rte.re'); + it('Import standalone with particular db index (format 1)', 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([ + { + ...importDatabaseFormat0, + name, + db: constants.TEST_REDIS_DB_INDEX, + } + ])), 'file.json'], + responseBody: { + total: 1, + success: [{ + index: 0, + status: 'success', + host: importDatabaseFormat0.host, + port: importDatabaseFormat0.port, + }], + partial: [], + fail: [], + }, + }); + + // 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) + }); + }); + }); + describe('TLS CA', function () { + requirements('rte.tls', '!rte.tlsAuth'); + it('Import standalone with CA 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 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: [], + }, + }); + + 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, + 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 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/api/database/GET-databases.test.ts b/redisinsight/api/test/api/database/GET-databases.test.ts index 23ba91e164..c128a6987a 100644 --- a/redisinsight/api/test/api/database/GET-databases.test.ts +++ b/redisinsight/api/test/api/database/GET-databases.test.ts @@ -11,7 +11,8 @@ const responseSchema = Joi.array().items(Joi.object().keys({ port: Joi.number().integer().required(), db: Joi.number().integer().allow(null).required(), name: Joi.string().required(), - connectionType: Joi.string().valid('STANDALONE', 'SENTINEL', 'CLUSTER').required(), + new: Joi.boolean().allow(null).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(), 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/api/test/api/database/POST-databases.test.ts b/redisinsight/api/test/api/database/POST-databases.test.ts index 7d8fee3d41..c43600a3dc 100644 --- a/redisinsight/api/test/api/database/POST-databases.test.ts +++ b/redisinsight/api/test/api/database/POST-databases.test.ts @@ -119,6 +119,7 @@ describe('POST /databases', () => { username: null, password: null, connectionType: constants.STANDALONE, + new: true, }, }); }); @@ -166,6 +167,7 @@ describe('POST /databases', () => { username: null, password: null, connectionType: constants.STANDALONE, + new: true, }, checkFn: ({ body }) => { addedId = body.id; @@ -235,10 +237,13 @@ describe('POST /databases', () => { username: null, password: constants.TEST_REDIS_PASSWORD, connectionType: constants.STANDALONE, + new: true, }, }); - 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 }); @@ -266,6 +271,7 @@ describe('POST /databases', () => { connectionType: constants.STANDALONE, tls: true, verifyServerCert: false, + new: true, }, }); @@ -299,6 +305,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 8e14b05eb0..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: { @@ -183,7 +163,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']), + ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new']), 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..ec4831d3f7 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), tls: Joi.boolean().allow(null), tlsServername: Joi.string().allow(null), verifyServerCert: Joi.boolean().allow(null), 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/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/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/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); 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" 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..62152a4dfd 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?.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..135531463d --- /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-feature')) + + 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..f26c1f5532 --- /dev/null +++ b/redisinsight/ui/src/components/hightlighted-feature/HighlightedFeature.tsx @@ -0,0 +1,77 @@ +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 + dataTestPostfix?: string +} +const HighlightedFeature = (props: Props) => { + const { + isHighlight, + children, + title, + content, + type = 'plain', + transformOnHover, + onClick, + wrapperClassName, + dotClassName, + tooltipPosition, + hideFirstChild, + dataTestPostfix = '' + } = 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-${dataTestPostfix}`} + > + {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/import-databases-dialog/ImportDatabasesDialog.spec.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx new file mode 100644 index 0000000000..ef6e712a3b --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx @@ -0,0 +1,116 @@ +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 not render error message without error', () => { + (importInstancesSelector as jest.Mock).mockImplementation(() => ({ + loading: false, + data: {} + })) + + render() + 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: null, + error: 'Error message' + })) + + render() + expect(screen.getByTestId('result-failed')).toBeInTheDocument() + expect(screen.getByTestId('result-failed')).toHaveTextContent('Error message') + }) +}) 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..5065e3ee6e --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx @@ -0,0 +1,167 @@ +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 cx from 'classnames' +import { + fetchInstancesAction, + importInstancesSelector, + resetImportInstances, + uploadInstancesFile +} 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' + +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 = () => { + if (data?.success?.length || data?.partial?.length) { + dispatch(fetchInstancesAction()) + } + onClose(!data) + dispatch(resetImportInstances()) + } + + 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 ( + + + + + {(!data && !error) ? 'Import Database Connections' : 'Import Results'} + + + + + + + + {isShowForm && ( + + + {isInvalid && ( + + File should not exceed {MAX_MB_FILE} MB + + )} + + )} + + {loading && ( +
+ + Uploading... +
+ )} + {data && ()} + {error && ( +
+ + + Failed to add database connections + + {error} +
+ )} +
+
+
+ + {data && ( + + + Ok + + + )} + + {isShowForm && ( + + + Cancel + + + + Import + + + )} +
+ ) +} + +export default ImportDatabasesDialog 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/ResultsLog.tsx b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.tsx new file mode 100644 index 0000000000..3446ea3dd3 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/components/ResultsLog/ResultsLog.tsx @@ -0,0 +1,87 @@ +import { EuiCollapsibleNavGroup } from '@elastic/eui' +import cx from 'classnames' +import React, { useState } from 'react' + +import { ImportDatabasesData } from 'uiSrc/slices/interfaces' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import TableResult from '../TableResult' + +import styles from './styles.module.scss' + +enum ResultsStatus { + Success = 'success', + Partial = 'partial', + Failed = 'failed' +} + +export interface Props { + data: ImportDatabasesData +} + +const ResultsLog = ({ data }: Props) => { + 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..8ce336e68f --- /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: var(--successBorderColor); + } + } + + &:global(.partial) { + &:before { + background-color: var(--warningBorderColor); + } + } + + &:global(.failed) { + &:before { + background-color: var(--errorBorderColor); + } + } + + &: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 var(--separatorColor); + } + .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.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/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/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..cb918d12c3 --- /dev/null +++ b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss @@ -0,0 +1,80 @@ +.modal { + background: var(--euiColorLightestShade) !important; + min-width: 500px !important; + 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; + } + + .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/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/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/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/DatabasesListComponent/DatabasesListWrapper.spec.tsx index 4cdcd2dc82..3fd6d7c316 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-new-${dbIdWithNewIndicator}`)).toBeInTheDocument() + expect(queryByTestId(`database-status-new-${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..b19b7fe823 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; + } +} 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..de6aa94762 100644 --- a/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx +++ b/redisinsight/ui/src/pages/home/components/HomeHeader/HomeHeader.tsx @@ -3,12 +3,21 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, + EuiIcon, EuiSpacer, EuiText, + 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' @@ -37,6 +46,11 @@ 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) + + const { importDatabases: importDatabasesHighlighting } = useSelector(appFeaturesToHighlightSelector) ?? {} + + const dispatch = useDispatch() useEffect(() => { if (loading || !data || isEmpty(data)) { @@ -74,6 +88,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 = () => ( + dispatch(removeFeatureFromHighlighting('importDatabases'))} + transformOnHover + hideFirstChild + > + + + + + + + ) + const Guides = () => (
@@ -150,82 +205,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/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/instances/instances.ts b/redisinsight/ui/src/slices/instances/instances.ts index c4f73575df..36f0bf7d9e 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 @@ -456,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 } @@ -485,3 +515,36 @@ 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)) + onFailAction?.() + } + } +} 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/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index 64260caed9..61234c9b6a 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -282,6 +282,39 @@ export interface InitialStateInstances { connectedInstance: Instance editedInstance: InitialStateEditedInstances instanceOverview: DatabaseConfigInfo + importInstances: { + loading: boolean + error: string + 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 { 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/slices/tests/instances/instances.spec.ts b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts index 66627e18f2..769c3d1f36 100644 --- a/redisinsight/ui/src/slices/tests/instances/instances.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/instances.spec.ts @@ -43,6 +43,12 @@ 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 +518,114 @@ 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 mockedError = { statusCode: 400, message: 'message', error: 'error' } + const data = { + 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, + 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 mockedError = { statusCode: 400, message: 'message', error: 'error' } + const currentState = { + ...initialState, + importInstances: { + ...initialState.importInstances, + data: { + 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 + } + + // 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 () => { @@ -909,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( @@ -933,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( @@ -998,5 +1112,57 @@ describe('instances slice', () => { expect(store.getActions()).toEqual(expectedActions) }) }) + + describe('uploadInstancesFile', () => { + it('should call proper actions on success', async () => { + // Arrange + const formData = new FormData() + const mockedError = { statusCode: 400, message: 'message', error: 'error' } + const data = { + 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 } + + 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), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) }) }) 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/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 2dee6e4865..8bf11c6c6c 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,11 @@ --overlayPromoNYColor: #{$overlayPromoNYColor}; --monacoBgColor: #{$monacoBgColor}; + --highlightDotColor: #{$highlightDotColor}; + + --successBorderColor: #{$successBorderColor}; + --warningBorderColor: #{$warningBorderColor}; + --errorBorderColor: #{$errorBorderColor}; // 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..3805a4afc1 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,11 @@ $commandGroupBadgeColor: #3f4b5f; $overlayPromoNYColor: #0000001a; $monacoBgColor: #111; +$highlightDotColor: #2BBBB2; + +$successBorderColor: #13A450; +$warningBorderColor: #9D6901; +$errorBorderColor: #AD0017; // 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..1db959d99f 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,11 @@ --overlayPromoNYColor: #{$overlayPromoNYColor}; --monacoBgColor: #{$monacoBgColor}; + --highlightDotColor: #{$highlightDotColor}; + + --successBorderColor: #{$successBorderColor}; + --warningBorderColor: #{$warningBorderColor}; + --errorBorderColor: #{$errorBorderColor}; // 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..b359cf7102 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,11 @@ $callOutBackgroundColor: #e9edfa; $overlayPromoNYColor: #ffffff1a; $monacoBgColor: #f0f2f7; +$highlightDotColor: #2BBBB2; + +$successBorderColor: #5BC69B; +$warningBorderColor: #FFAF2B; +$errorBorderColor: #F74B57; // Types colors $typeHashColor: #cdddf8; diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index ea9b2bf88f..435ae4db64 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -27,6 +27,10 @@ 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', + 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', 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 }) + }) +}) diff --git a/tests/e2e/common-actions/databases-actions.ts b/tests/e2e/common-actions/databases-actions.ts new file mode 100644 index 0000000000..043966c937 --- /dev/null +++ b/tests/e2e/common-actions/databases-actions.ts @@ -0,0 +1,60 @@ +import { t } from 'testcafe'; +import * as fs from 'fs'; +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 of databases) { + const databaseName = myRedisDatabasePage.dbNameList.withText(db); + await t.expect(databaseName.exists).ok(`"${db}" database doesn't exist`); + } + } + + /** + * Import database using file + * @param fileParameters The arguments of imported file + */ + async importDatabase(fileParameters: ImportDatabaseParameters): Promise { + await t + .click(myRedisDatabasePage.importDatabasesBtn) + .setFilesToUpload(myRedisDatabasePage.importDatabaseInput, [fileParameters.path]) + .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')); + } +} + +/** + * 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 + * @param parsedJson The parsed json content + */ +export type ImportDatabaseParameters = { + path: string, + type?: string, + dbNames?: string[], + userName?: string, + password?: string, + connectionType?: string, + fileName?: string, + parsedJson?: any +}; 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/helpers/api/api-database.ts b/tests/e2e/helpers/api/api-database.ts index be3a5c09a6..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); @@ -152,6 +152,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 @@ -169,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/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/add-redis-database-page.ts b/tests/e2e/pageObjects/add-redis-database-page.ts index ff93abfd52..fc71fb7a1d 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,10 +43,15 @@ 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]'); + sentinelForm = Selector('[data-testid=form]'); //Links 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 @@ -59,7 +65,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 }); } @@ -80,7 +86,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 }); } @@ -126,8 +132,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 e749a9d795..23797c4bc8 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]'); @@ -15,7 +17,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 +32,11 @@ 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]'); + removeImportedFileBtn = Selector('[aria-label="Clear selected files"]'); //CHECKBOXES selectAllCheckbox = Selector('[data-test-subj=checkboxSelectAll]'); //ICONS @@ -46,6 +53,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 +63,14 @@ 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]'); + 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 @@ -167,4 +183,69 @@ 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'); + } + + /** + * Filter array with database objects by result field and return names + * @param listOfDb Actual databases list + * @param result The expected import result + */ + getDatabaseNamesFromListByResult(listOfDb: DatabasesForImport, result: string): 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/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/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/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/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 new file mode 100644 index 0000000000..3a18087819 --- /dev/null +++ b/tests/e2e/test-data/import-databases/ardm-valid.ano @@ -0,0 +1 @@ +W3siaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiI2Mzc5IiwiYXV0aCI6InBhc3MiLCJ1c2VybmFtZSI6InVzZXJuYW1lVGVzdCIsIm5hbWUiOiJhcmRtV2l0aFBhc3NBbmRVc2VybmFtZSIsInNlcGFyYXRvciI6IjoiLCJjbHVzdGVyIjpmYWxzZSwia2V5IjoiMTY1MDM3MzUyNTY1MV9naHFwciIsIm9yZGVyIjowfSx7Imhvc3QiOiJhcmRtTm9OYW1lIiwicG9ydCI6IjEyMDAxIiwiYXV0aCI6IiIsInVzZXJuYW1lIjoiIiwic2VwYXJhdG9yIjoiOiIsImNsdXN0ZXIiOmZhbHNlLCJrZXkiOiIxNjUwODk3NjIxNzU0X2I1bjB2Iiwib3JkZXIiOjF9LHsiaG9zdCI6Im9zcy1zZW50aW5lbCIsInBvcnQiOiIyNjM3OSIsIm5hbWUiOiJhcmRtU2VudGluZWwiLCJhdXRoIjoicGFzc3dvcmQiLCJzZW50aW5lbE9wdGlvbnMiOnsibWFzdGVyTmFtZSI6InByaW1hcnktZ3JvdXAtMSIsIm5vZGVQYXNzd29yZCI6ImRlZmF1bHRwYXNzIn19XQ== \ No newline at end of file diff --git a/tests/e2e/test-data/import-databases/racompass-invalid.json b/tests/e2e/test-data/import-databases/racompass-invalid.json new file mode 100644 index 0000000000..fac9e31a5a --- /dev/null +++ b/tests/e2e/test-data/import-databases/racompass-invalid.json @@ -0,0 +1,170 @@ +[ + { + "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/import-databases/racompass-valid.json b/tests/e2e/test-data/import-databases/racompass-valid.json new file mode 100644 index 0000000000..7cac1af195 --- /dev/null +++ b/tests/e2e/test-data/import-databases/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", + "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/test-data/import-databases/rdm-full.json b/tests/e2e/test-data/import-databases/rdm-full.json new file mode 100644 index 0000000000..6e7a00af9d --- /dev/null +++ b/tests/e2e/test-data/import-databases/rdm-full.json @@ -0,0 +1,219 @@ +[ + { + "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", + "indName": "rdmHost+Port+Name+Index [2]" + }, + { + "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": "/root/certs/ca.crt", + "result": "success" + }, + { + "host": "localhost", + "port": 8102, + "name": "rdmHost+Port+Name+clientCert+privateKey", + "ssl": true, + "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": "/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": "/root/certs/ca.crt", + "ssl_local_cert_path": "/root/certs/client.crt", + "ssl_private_key_path": "/root/certs/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": "/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" + }, + { + "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": "/root/certs/client.crt", + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmOnlyPrivateKey", + "ssl_private_key_path": "/root/certs/client.key", + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmInvalidClientCert", + "ssl_local_cert_path": "invalid client cert", + "ssl_private_key_path": "/root/certs/client.key", + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmInvalidPrivateKey", + "ssl_local_cert_path": "/root/certs/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": "/root/certs/caInvalid.crt", + "result": "partial" + }, + { + "host": "localhost", + "port": 8103, + "name": "rdmClientCert+PrivateKeyInvalidPathes", + "ssl_local_cert_path": "/root/certs/clientInvalid.crt", + "ssl_private_key_path": "/root/certs/clientInvalid.key", + "result": "partial" + } +] 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..dd292f834c 100644 --- a/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts +++ b/tests/e2e/tests/critical-path/database/clone-databases.e2e.ts @@ -21,12 +21,12 @@ 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 +37,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); + + // 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); @@ -53,12 +57,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 +70,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 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') @@ -77,13 +85,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 +102,10 @@ test .meta({ rte: rte.sentinel })('Verify that user can clone Sentinel', async t => { await clickOnEditDatabaseByName(ossSentinelConfig.name[1]); await t.click(addRedisDatabasePage.cloneDatabaseButton); + + // 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/critical-path/database/import-databases.e2e.ts b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts new file mode 100644 index 0000000000..ce46029b7f --- /dev/null +++ b/tests/e2e/tests/critical-path/database/import-databases.e2e.ts @@ -0,0 +1,210 @@ +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'; +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 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 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: 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 = [ + { + type: 'racompass', + path: path.join('..', '..', '..', 'test-data', 'import-databases', racompassValidJson), + dbNames: ['racompassCluster', 'racompassDbWithIndex:8100 [1]'] + }, + { + type: 'ardm', + path: path.join('..', '..', '..', 'test-data', 'import-databases', ardmValidAno), + dbNames: ['ardmNoName:12001', 'ardmWithPassAndUsername', 'ardmSentinel'] + } +]; +const databasesToDelete = [ + dbData[0].dbNames[0], + dbData[0].dbNames[1].split(' ')[0], + ...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 }) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTerms(); + }); +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'; + 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, [racompassInvalidJsonPath]) + .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'); +}); +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(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'); + await myRedisDatabasePage.clickOnDBByName(dbData[1].dbNames[2]); + await t.expect(getPageUrl()).contains('browser', 'Sentinel connection not opened'); + }); +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'); + }); 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..bee944e72c 100644 --- a/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts +++ b/tests/e2e/tests/smoke/database/add-standalone-db.e2e.ts @@ -1,11 +1,24 @@ -import { addNewStandaloneDatabase, addNewREClusterDatabase, addNewRECloudDatabase, addOSSClusterDatabase, acceptLicenseTerms, deleteDatabase } from '../../../helpers/database'; +import { t } from 'testcafe'; +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'; + +const browserPage = new BrowserPage(); +const myRedisDatabasePage = new MyRedisDatabasePage(); fixture `Add database` .meta({ type: 'smoke' }) @@ -19,6 +32,12 @@ test await deleteDatabase(ossStandaloneConfig.databaseName); })('Verify that user can add Standalone Database', async() => { await addNewStandaloneDatabase(ossStandaloneConfig); + // 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); + // Verify that user can't see an indicator of databases that were opened + await myRedisDatabasePage.verifyDatabaseStatusIsNotVisible(); }); test .meta({ rte: rte.reCluster }) @@ -26,17 +45,23 @@ test 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 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}) + .meta({ env: env.web, rte: rte.ossCluster }) .after(async() => { await deleteDatabase(ossClusterConfig.ossClusterDatabaseName); })('Verify that user can add OSS Cluster DB', async() => { await addOSSClusterDatabase(ossClusterConfig); + // Verify new connection badge for OSS cluster + await myRedisDatabasePage.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 - await addNewRECloudDatabase('', ''); + await acceptLicenseTermsAndAddRECloudDatabase(cloudDatabaseConfig); + // 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;