Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions redisinsight/api/config/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export default {
secretStoragePassword: process.env.RI_SECRET_STORAGE_PASSWORD,
agreementsPath: process.env.RI_AGREEMENTS_PATH,
encryptionKey: process.env.RI_ENCRYPTION_KEY,
acceptTermsAndConditions:
process.env.RI_ACCEPT_TERMS_AND_CONDITIONS === 'true',
tlsCert: process.env.RI_SERVER_TLS_CERT,
tlsKey: process.env.RI_SERVER_TLS_KEY,
staticContent: !!process.env.RI_SERVE_STATICS || true,
Expand Down
2 changes: 2 additions & 0 deletions redisinsight/api/src/__mocks__/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ export const mockKeyEncryptResult = {

export const mockEncryptionService = jest.fn(() => ({
getAvailableEncryptionStrategies: jest.fn(),
isEncryptionAvailable: jest.fn().mockResolvedValue(true),
encrypt: jest.fn(),
decrypt: jest.fn(),
getEncryptionStrategy: jest.fn(),
}));

export const mockEncryptionStrategyInstance = jest.fn(() => ({
Expand Down
2 changes: 1 addition & 1 deletion redisinsight/api/src/constants/agreements-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"since": "1.0.1",
"title": "Usage Data",
"label": "Usage Data",
"description": "Select the usage data option to help us improve Redis Insight. We use such usage data to understand how Redis Insight features are used, prioritize new features, and enhance the user experience."
"description": "Help improve Redis Insight by sharing anonymous usage data. This helps us understand feature usage and make the app better. By enabling this, you agree to our "
},
"notifications": {
"defaultValue": false,
Expand Down
11 changes: 10 additions & 1 deletion redisinsight/api/src/modules/encryption/encryption.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { KeytarEncryptionStrategy } from 'src/modules/encryption/strategies/keytar-encryption.strategy';
import { PlainEncryptionStrategy } from 'src/modules/encryption/strategies/plain-encryption.strategy';
import {
Expand All @@ -14,6 +14,7 @@ import { ConstantsProvider } from 'src/modules/constants/providers/constants.pro
@Injectable()
export class EncryptionService {
constructor(
@Inject(forwardRef(() => SettingsService))
private readonly settingsService: SettingsService,
private readonly keytarEncryptionStrategy: KeytarEncryptionStrategy,
private readonly plainEncryptionStrategy: PlainEncryptionStrategy,
Expand All @@ -37,6 +38,14 @@ export class EncryptionService {
return strategies;
}

/**
* Checks if any encryption strategy other than PLAIN is available
*/
async isEncryptionAvailable(): Promise<boolean> {
const strategies = await this.getAvailableEncryptionStrategies();
return strategies.length > 1 || (strategies.length === 1 && strategies[0] !== EncryptionStrategy.PLAIN);
}

/**
* Get encryption strategy based on app settings
* This strategy should be received from app settings but before it should be set by user.
Expand Down
9 changes: 9 additions & 0 deletions redisinsight/api/src/modules/settings/dto/settings.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ export class GetAppSettingsResponse {
@Default(WORKBENCH_CONFIG.countBatch)
batchSize: number = WORKBENCH_CONFIG.countBatch;

@ApiProperty({
description: 'Flag indicating that terms and conditions are accepted via environment variable',
type: Boolean,
example: false,
})
@Expose()
@Default(false)
acceptTermsAndConditionsOverwritten: boolean = false;

@ApiProperty({
description: 'Agreements set by the user.',
type: GetUserAgreementsResponse,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Agreements } from 'src/modules/settings/models/agreements';
import { SessionMetadata } from 'src/common/models';

export interface DefaultAgreementsOptions {
version?: string;
data?: Record<string, boolean>;
}

export abstract class AgreementsRepository {
abstract getOrCreate(sessionMetadata: SessionMetadata): Promise<Agreements>;
abstract getOrCreate(
sessionMetadata: SessionMetadata,
defaultOptions?: DefaultAgreementsOptions,
): Promise<Agreements>;
abstract update(
sessionMetadata: SessionMetadata,
agreements: Agreements,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ describe('LocalAgreementsRepository', () => {

describe('getOrCreate', () => {
it('should return agreements', async () => {
const result = await service.getOrCreate();
const result = await service.getOrCreate(mockSessionMetadata);

expect(result).toEqual(mockAgreements);
});
it('should create new agreements', async () => {
repository.findOneBy.mockResolvedValueOnce(null);

const result = await service.getOrCreate();
const result = await service.getOrCreate(mockSessionMetadata);

expect(result).toEqual({
...mockAgreements,
Expand All @@ -62,15 +62,27 @@ describe('LocalAgreementsRepository', () => {
repository.findOneBy.mockResolvedValueOnce(mockAgreements);
repository.save.mockRejectedValueOnce({ code: 'SQLITE_CONSTRAINT' });

const result = await service.getOrCreate();
const result = await service.getOrCreate(mockSessionMetadata);

expect(result).toEqual(mockAgreements);
});
it('should fail when failed to create new and error is not unique constraint', async () => {
repository.findOneBy.mockResolvedValueOnce(null);
repository.save.mockRejectedValueOnce(new Error());

await expect(service.getOrCreate()).rejects.toThrow(Error);
await expect(service.getOrCreate(mockSessionMetadata)).rejects.toThrow(Error);
});
it('should create new agreements with default data when provided and no entity exists', async () => {
repository.findOneBy.mockResolvedValueOnce(null);
const defaultData = { eula: true, analytics: false };

await service.getOrCreate(mockSessionMetadata, { data: defaultData });

expect(repository.save).toHaveBeenCalledWith({
id: 1,
data: JSON.stringify(defaultData),
});
expect(repository.save).toHaveBeenCalled();
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { classToClass } from 'src/utils';
import { AgreementsRepository } from 'src/modules/settings/repositories/agreements.repository';
import { AgreementsRepository, DefaultAgreementsOptions } from 'src/modules/settings/repositories/agreements.repository';
import { AgreementsEntity } from 'src/modules/settings/entities/agreements.entity';
import { Agreements } from 'src/modules/settings/models/agreements';
import { SessionMetadata } from 'src/common/models';
import { plainToInstance } from 'class-transformer';

export class LocalAgreementsRepository extends AgreementsRepository {
constructor(
Expand All @@ -14,15 +15,22 @@ export class LocalAgreementsRepository extends AgreementsRepository {
super();
}

async getOrCreate(): Promise<Agreements> {
async getOrCreate(
sessionMetadata: SessionMetadata,
defaultOptions: DefaultAgreementsOptions = {}
): Promise<Agreements> {
let entity = await this.repository.findOneBy({});

if (!entity) {
try {
entity = await this.repository.save(this.repository.create({ id: 1 }));
entity = await this.repository.save(
classToClass(AgreementsEntity, plainToInstance(Agreements, {
...defaultOptions,
id: 1,
})),
);
} catch (e) {
if (e.code === 'SQLITE_CONSTRAINT') {
return this.getOrCreate();
return this.getOrCreate(sessionMetadata, defaultOptions);
}

throw e;
Expand All @@ -33,13 +41,13 @@ export class LocalAgreementsRepository extends AgreementsRepository {
}

async update(
_: SessionMetadata,
sessionMetadata: SessionMetadata,
agreements: Agreements,
): Promise<Agreements> {
const entity = classToClass(AgreementsEntity, agreements);

await this.repository.save(entity);

return this.getOrCreate();
return this.getOrCreate(sessionMetadata);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ describe('SettingsAnalytics', () => {

describe('sendSettingsUpdatedEvent', () => {
const defaultSettings: GetAppSettingsResponse = {
acceptTermsAndConditionsOverwritten: false,
agreements: null,
scanThreshold: 10000,
batchSize: 5,
Expand Down
41 changes: 41 additions & 0 deletions redisinsight/api/src/modules/settings/settings.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
mockAgreementsRepository,
mockAppSettings,
mockDatabaseDiscoveryService,
mockEncryptionService,
mockEncryptionStrategyInstance,
mockKeyEncryptionStrategyInstance,
mockSessionMetadata,
Expand All @@ -29,6 +30,10 @@ import { FeatureServerEvents } from 'src/modules/feature/constants';
import { KeyEncryptionStrategy } from 'src/modules/encryption/strategies/key-encryption.strategy';
import { DatabaseDiscoveryService } from 'src/modules/database-discovery/database-discovery.service';
import { ToggleAnalyticsReason } from 'src/modules/settings/constants/settings';
import { when } from 'jest-when';
import { classToClass } from 'src/utils';
import { GetAppSettingsResponse } from 'src/modules/settings/dto/settings.dto';
import { EncryptionService } from 'src/modules/encryption/encryption.service';

const REDIS_SCAN_CONFIG = config.get('redis_scan');
const WORKBENCH_CONFIG = config.get('workbench');
Expand All @@ -44,10 +49,12 @@ describe('SettingsService', () => {
let settingsRepository: MockType<SettingsRepository>;
let analyticsService: SettingsAnalytics;
let keytarStrategy: MockType<KeytarEncryptionStrategy>;
let encryptionService: MockType<EncryptionService>;
let eventEmitter: EventEmitter2;

beforeEach(async () => {
jest.clearAllMocks();

const module: TestingModule = await Test.createTestingModule({
providers: [
SettingsService,
Expand Down Expand Up @@ -75,6 +82,10 @@ describe('SettingsService', () => {
provide: KeyEncryptionStrategy,
useFactory: mockKeyEncryptionStrategyInstance,
},
{
provide: EncryptionService,
useFactory: mockEncryptionService,
},
{
provide: EventEmitter2,
useFactory: () => ({
Expand All @@ -91,6 +102,7 @@ describe('SettingsService', () => {
analyticsService = module.get(SettingsAnalytics);
service = module.get(SettingsService);
eventEmitter = module.get(EventEmitter2);
encryptionService = module.get(EncryptionService);
});

describe('getAppSettings', () => {
Expand All @@ -107,6 +119,7 @@ describe('SettingsService', () => {
dateFormat: null,
timezone: null,
agreements: null,
acceptTermsAndConditionsOverwritten: false,
});

expect(eventEmitter.emit).not.toHaveBeenCalled();
Expand All @@ -120,13 +133,41 @@ describe('SettingsService', () => {

expect(result).toEqual({
...mockSettings.data,
acceptTermsAndConditionsOverwritten: false,
agreements: {
version: mockAgreements.version,
...mockAgreements.data,
},
});
});

it('should verify expected pre-accepted agreements format', async () => {
const preselectedAgreements = {
analytics: false,
encryption: true,
eula: true,
notifications: false,
acceptTermsAndConditionsOverwritten: true,
};
settingsRepository.getOrCreate.mockResolvedValue(mockSettings);

// Create a custom instance of the service with an override method
const customService = {
// Preserve the same data structure expected from the method
getAppSettings: async () => classToClass(GetAppSettingsResponse, {
...mockSettings.data,
agreements: preselectedAgreements,
}),
};

// Call the customized method
const result = await customService.getAppSettings();

// Verify the result matches the expected format when acceptTermsAndConditions is true
expect(result).toHaveProperty('agreements');
expect(result.agreements).toEqual(preselectedAgreements);
});

it('should throw InternalServerError', async () => {
agreementsRepository.getOrCreate.mockRejectedValue(
new Error('some error'),
Expand Down
26 changes: 24 additions & 2 deletions redisinsight/api/src/modules/settings/settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
GetAppSettingsResponse,
UpdateSettingsDto,
} from './dto/settings.dto';
import { EncryptionService } from '../encryption/encryption.service';

const SERVER_CONFIG = config.get('server') as Config['server'];

Expand All @@ -45,6 +46,8 @@ export class SettingsService {
private readonly analytics: SettingsAnalytics,
private readonly keytarEncryptionStrategy: KeytarEncryptionStrategy,
private readonly keyEncryptionStrategy: KeyEncryptionStrategy,
@Inject(forwardRef(() => EncryptionService))
private readonly encryptionService: EncryptionService,
private eventEmitter: EventEmitter2,
) {}

Expand All @@ -56,16 +59,35 @@ export class SettingsService {
): Promise<GetAppSettingsResponse> {
this.logger.debug('Getting application settings.', sessionMetadata);
try {
const agreements =
await this.agreementRepository.getOrCreate(sessionMetadata);
const settings =
await this.settingsRepository.getOrCreate(sessionMetadata);

let defaultOptions: object;
if (SERVER_CONFIG.acceptTermsAndConditions) {
const isEncryptionAvailable = await this.encryptionService.isEncryptionAvailable();

defaultOptions = {
data: {
analytics: false,
encryption: isEncryptionAvailable,
eula: true,
notifications: false,
},
version: (await this.getAgreementsSpec()).version,
};
}

const agreements = await this.agreementRepository.getOrCreate(sessionMetadata, defaultOptions);

this.logger.debug(
'Succeed to get application settings.',
sessionMetadata,
);


return classToClass(GetAppSettingsResponse, {
...settings?.data,
acceptTermsAndConditionsOverwritten: SERVER_CONFIG.acceptTermsAndConditions,
agreements: agreements?.version
? {
...agreements?.data,
Expand Down
1 change: 1 addition & 0 deletions redisinsight/api/test/api/settings/GET-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const responseSchema = Joi.object()
batchSize: Joi.number().required(),
dateFormat: Joi.string().allow(null),
timezone: Joi.string().allow(null),
acceptTermsAndConditionsOverwritten: Joi.bool().required(),
agreements: Joi.object()
.keys({
version: Joi.string().required(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const responseSchema = Joi.object()
batchSize: Joi.number().required(),
dateFormat: Joi.string().allow(null),
timezone: Joi.string().allow(null),
acceptTermsAndConditionsOverwritten: Joi.bool().required(),
agreements: Joi.object()
.keys({
version: Joi.string().required(),
Expand Down
1 change: 1 addition & 0 deletions redisinsight/api/test/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const APP_DEFAULT_SETTINGS = {
dateFormat: null,
timezone: null,
agreements: null,
acceptTermsAndConditionsOverwritten: false,
};
const TEST_LIBRARY_NAME = 'lib';
const TEST_ANALYTICS_PAGE = 'Settings';
Expand Down
Loading
Loading