Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions redisinsight/api/src/__mocks__/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export const mockRedisNoPermError: ReplyError = {
message: 'NOPERM this user has no permissions.',
};

export const mockRedisUnknownIndexName: ReplyError = {
name: 'ReplyError',
command: 'FT.INFO',
message: 'Unknown Index name',
};

export const mockRedisWrongNumberOfArgumentsError: ReplyError = {
name: 'ReplyError',
command: 'GET',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class RedisearchController extends BaseController {
@ApiRedisParams()
@HttpCode(201)
@ApiBody({ type: CreateRedisearchIndexDto })
async createList(
async createIndex(
@Param('dbInstance') dbInstance: string,
@Body() dto: CreateRedisearchIndexDto,
): Promise<void> {
Expand All @@ -62,6 +62,7 @@ export class RedisearchController extends BaseController {
}

@Post('search')
@HttpCode(200)
@ApiOperation({ description: 'Search for keys in index' })
@ApiOkResponse({ type: GetKeysWithDetailsResponse })
@ApiRedisParams()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { when } from 'jest-when';
import {
mockRedisConsumer,
mockRedisNoPermError,
mockRedisUnknownIndexName,
mockStandaloneDatabaseEntity,
} from 'src/__mocks__';
import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service';
import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service';
import { RedisearchService } from 'src/modules/browser/services/redisearch/redisearch.service';
import IORedis from 'ioredis';
import {
RedisearchIndexDataType,
RedisearchIndexKeyType,
} from 'src/modules/browser/dto/redisearch';

const nodeClient = Object.create(IORedis.prototype);
nodeClient.sendCommand = jest.fn();

const clusterClient = Object.create(IORedis.Cluster.prototype);
clusterClient.sendCommand = jest.fn();
clusterClient.nodes = jest.fn();

const keyName1 = Buffer.from('keyName1');
const keyName2 = Buffer.from('keyName2');

const mockClientOptions: IFindRedisClientInstanceByOptions = {
instanceId: mockStandaloneDatabaseEntity.id,
};

const mockCreateRedisearchIndexDto = {
index: 'indexName',
type: RedisearchIndexKeyType.HASH,
prefixes: ['device:', 'user:'],
fields: [
{
name: 'text:field',
type: RedisearchIndexDataType.TEXT,
},
{
name: 'coordinates:field',
type: RedisearchIndexDataType.GEO,
},
],
};
const mockSearchRedisearchDto = {
index: 'indexName',
query: 'somequery:',
limit: 10,
offset: 0,
};

describe('RedisearchService', () => {
let service: RedisearchService;
let browserTool;

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

const module: TestingModule = await Test.createTestingModule({
providers: [
RedisearchService,
{
provide: BrowserToolService,
useFactory: mockRedisConsumer,
},
],
}).compile();

service = module.get<RedisearchService>(RedisearchService);
browserTool = module.get<BrowserToolService>(BrowserToolService);
browserTool.getRedisClient.mockResolvedValue(nodeClient);
clusterClient.nodes.mockReturnValue([nodeClient, nodeClient]);
});

describe('list', () => {
it('should get list of indexes for standalone', async () => {
nodeClient.sendCommand.mockResolvedValue([
keyName1.toString('hex'),
keyName2.toString('hex'),
]);

const list = await service.list(mockClientOptions);

expect(list).toEqual({
indexes: [
keyName1,
keyName2,
],
});
});
it('should get list of indexes for cluster (handle unique index name)', async () => {
browserTool.getRedisClient.mockResolvedValue(clusterClient);
nodeClient.sendCommand.mockResolvedValue([
keyName1.toString('hex'),
keyName2.toString('hex'),
]);

const list = await service.list(mockClientOptions);

expect(list).toEqual({
indexes: [
keyName1,
keyName2,
],
});
});
it('should handle ACL error', async () => {
nodeClient.sendCommand.mockRejectedValueOnce(mockRedisNoPermError);

try {
await service.list(mockClientOptions);
fail();
} catch (e) {
expect(e).toBeInstanceOf(ForbiddenException);
}
});
});

describe('createIndex', () => {
it('should create index for standalone', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.INFO' }))
.mockRejectedValue(mockRedisUnknownIndexName);
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.CREATE' }))
.mockResolvedValue('OK');

await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto);

expect(nodeClient.sendCommand).toHaveBeenCalledTimes(2);
expect(nodeClient.sendCommand).toHaveBeenCalledWith(jasmine.objectContaining({
name: 'FT.CREATE',
args: [
mockCreateRedisearchIndexDto.index,
'ON', mockCreateRedisearchIndexDto.type,
'PREFIX', '2', ...mockCreateRedisearchIndexDto.prefixes,
'SCHEMA', mockCreateRedisearchIndexDto.fields[0].name, mockCreateRedisearchIndexDto.fields[0].type,
mockCreateRedisearchIndexDto.fields[1].name, mockCreateRedisearchIndexDto.fields[1].type,
],
}));
});
it('should create index for cluster', async () => {
browserTool.getRedisClient.mockResolvedValue(clusterClient);
when(clusterClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.INFO' }))
.mockRejectedValue(mockRedisUnknownIndexName);
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.CREATE' }))
.mockResolvedValueOnce('OK').mockRejectedValue(new Error('ReplyError: MOVED to somenode'));

await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto);

expect(clusterClient.sendCommand).toHaveBeenCalledTimes(1);
expect(nodeClient.sendCommand).toHaveBeenCalledTimes(2);
expect(nodeClient.sendCommand).toHaveBeenCalledWith(jasmine.objectContaining({
name: 'FT.CREATE',
args: [
mockCreateRedisearchIndexDto.index,
'ON', mockCreateRedisearchIndexDto.type,
'PREFIX', '2', ...mockCreateRedisearchIndexDto.prefixes,
'SCHEMA', mockCreateRedisearchIndexDto.fields[0].name, mockCreateRedisearchIndexDto.fields[0].type,
mockCreateRedisearchIndexDto.fields[1].name, mockCreateRedisearchIndexDto.fields[1].type,
],
}));
});
it('should handle already existing index error', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.INFO' }))
.mockReturnValue({ any: 'data' });

try {
await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto);
fail();
} catch (e) {
expect(e).toBeInstanceOf(ConflictException);
}
});
it('should handle ACL error (ft.info command)', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.INFO' }))
.mockRejectedValue(mockRedisNoPermError);

try {
await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto);
fail();
} catch (e) {
expect(e).toBeInstanceOf(ForbiddenException);
}
});
it('should handle ACL error (ft.create command)', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.INFO' }))
.mockRejectedValue(mockRedisUnknownIndexName);
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.CREATE' }))
.mockRejectedValue(mockRedisNoPermError);

try {
await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto);
fail();
} catch (e) {
expect(e).toBeInstanceOf(ForbiddenException);
}
});
});

describe('search', () => {
it('should search in standalone', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.SEARCH' }))
.mockResolvedValue([100, keyName1, keyName2]);

const res = await service.search(mockClientOptions, mockSearchRedisearchDto);

expect(res).toEqual({
cursor: mockSearchRedisearchDto.limit + mockSearchRedisearchDto.offset,
scanned: 2,
total: 100,
keys: [{
name: keyName1,
}, {
name: keyName2,
}],
});

expect(nodeClient.sendCommand).toHaveBeenCalledTimes(1);
expect(nodeClient.sendCommand).toHaveBeenCalledWith(jasmine.objectContaining({
name: 'FT.SEARCH',
args: [
mockSearchRedisearchDto.index,
mockSearchRedisearchDto.query,
'NOCONTENT',
'LIMIT', `${mockSearchRedisearchDto.offset}`, `${mockSearchRedisearchDto.limit}`,
],
}));
});
it('should search in cluster', async () => {
browserTool.getRedisClient.mockResolvedValue(clusterClient);
when(clusterClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.SEARCH' }))
.mockResolvedValue([100, keyName1, keyName2]);

const res = await service.search(mockClientOptions, mockSearchRedisearchDto);

expect(res).toEqual({
cursor: mockSearchRedisearchDto.limit + mockSearchRedisearchDto.offset,
scanned: 2,
total: 100,
keys: [
{ name: keyName1 },
{ name: keyName2 },
],
});

expect(clusterClient.sendCommand).toHaveBeenCalledTimes(1);
expect(clusterClient.sendCommand).toHaveBeenCalledWith(jasmine.objectContaining({
name: 'FT.SEARCH',
args: [
mockSearchRedisearchDto.index,
mockSearchRedisearchDto.query,
'NOCONTENT',
'LIMIT', `${mockSearchRedisearchDto.offset}`, `${mockSearchRedisearchDto.limit}`,
],
}));
});
it('should handle ACL error (ft.info command)', async () => {
when(nodeClient.sendCommand)
.calledWith(jasmine.objectContaining({ name: 'FT.SEARCH' }))
.mockRejectedValue(mockRedisNoPermError);

try {
await service.search(mockClientOptions, mockSearchRedisearchDto);
fail();
} catch (e) {
expect(e).toBeInstanceOf(ForbiddenException);
}
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { BrowserToolService } from '../browser-tool/browser-tool.service';

@Injectable()
export class RedisearchService {
private logger = new Logger('ListBusinessService');
private logger = new Logger('RedisearchService');

constructor(
private browserTool: BrowserToolService,
Expand Down Expand Up @@ -83,7 +83,9 @@ export class RedisearchService {
);
}
} catch (error) {
// ignore any kind of error
if (!error.message?.includes('Unknown Index name')) {
throw error;
}
}

const nodes = this.getShards(client);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
expect,
describe,
before,
deps,
requirements,
getMainCheckFn,
} from '../deps';
const { server, request, constants, rte } = deps;

// endpoint to test
const endpoint = (instanceId = constants.TEST_INSTANCE_ID) =>
request(server).get(`/instance/${instanceId}/redisearch`);

const mainCheckFn = getMainCheckFn(endpoint);

describe('GET /databases/:id/redisearch', () => {
requirements('!rte.bigData', 'rte.modules.search');

describe('Common', () => {
before(async () => rte.data.generateRedisearchIndexes(true));

[
{
name: 'Should get index list',
checkFn: async ({ body }) => {
expect(body.indexes.length).to.eq(2)
expect(body.indexes).to.include(
constants.TEST_SEARCH_HASH_INDEX_1,
constants.TEST_SEARCH_HASH_INDEX_2,
);
},
},
].map(mainCheckFn);
});

describe('ACL', () => {
requirements('rte.acl');
before(async () => rte.data.setAclUserRules('~* +@all'));

[
{
name: 'Should get index list',
endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID),
},
{
name: 'Should throw error if no permissions for "ft._list" command',
endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID),
statusCode: 403,
responseBody: {
statusCode: 403,
error: 'Forbidden',
},
before: () => rte.data.setAclUserRules('~* +@all -ft._list')
},
].map(mainCheckFn);
});
});
Loading