Skip to content
Open
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
5 changes: 4 additions & 1 deletion redisinsight/api/src/constants/redis-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ export const REDIS_MODULES_COMMANDS = new Map([
],
[AdditionalRedisModuleName.RedisJSON, ['json.get']],
[AdditionalRedisModuleName.RediSearch, ['ft.info']],
[AdditionalRedisModuleName.RedisTimeSeries, ['ts.mrange', 'ts.info']],
[
AdditionalRedisModuleName.RedisTimeSeries,
['ts.mrange', 'ts.info', 'ts.range', 'ts.revrange'],
],
]);

export const REDISEARCH_MODULES: string[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ describe('DatabaseInfoProvider', () => {
);
expect(result).toEqual([{ name: AdditionalRedisModuleName.RediSearch }]);
});
it('should return empty array if MODULE LIST and COMMAND command not allowed', async () => {
it('should return empty array if MODULE LIST, COMMAND INFO, and HELLO/INFO fallback find no modules', async () => {
when(standaloneClient.call)
.calledWith(['module', 'list'], expect.anything())
.mockRejectedValue(mockUnknownCommandModule);
Expand All @@ -380,11 +380,103 @@ describe('DatabaseInfoProvider', () => {
expect.anything(),
)
.mockRejectedValue(mockUnknownCommandModule);
when(standaloneClient.call)
.calledWith(['hello'], expect.anything())
.mockRejectedValue(mockUnknownCommandModule);
when(standaloneClient.getInfo).mockResolvedValue({});

const result = await service.determineDatabaseModules(standaloneClient);

expect(result).toEqual([]);
});
it('should detect modules from HELLO when MODULE LIST and COMMAND INFO are not allowed', async () => {
when(standaloneClient.call)
.calledWith(['module', 'list'], expect.anything())
.mockRejectedValue(mockUnknownCommandModule);
when(standaloneClient.call)
.calledWith(
expect.arrayContaining(['command', 'info']),
expect.anything(),
)
.mockRejectedValue(mockUnknownCommandModule);
when(standaloneClient.call)
.calledWith(['hello'], expect.anything())
.mockResolvedValue([
'server',
'redis',
'version',
'7.4.0',
'modules',
[
['name', 'timeseries', 'ver', 11000],
['name', 'search', 'ver', 21000],
],
]);

const result = await service.determineDatabaseModules(standaloneClient);

expect(result).toEqual([
{
name: AdditionalRedisModuleName.RedisTimeSeries,
version: 11000,
semanticVersion: '1.10.0',
},
{
name: AdditionalRedisModuleName.RediSearch,
version: 21000,
semanticVersion: '2.10.0',
},
]);
});
it('should call HELLO even when INFO returns collapsed module lines', async () => {
when(standaloneClient.call)
.calledWith(['module', 'list'], expect.anything())
.mockRejectedValue(mockUnknownCommandModule);
when(standaloneClient.call)
.calledWith(
expect.arrayContaining(['command', 'info']),
expect.anything(),
)
.mockRejectedValue(mockUnknownCommandModule);
// INFO # Modules collapses duplicate keys to a single unusable entry
when(standaloneClient.getInfo).mockResolvedValue({
modules: {
module: 'name=timeseries,ver=11206,api=1',
},
});
when(standaloneClient.call)
.calledWith(['hello'], expect.anything())
.mockResolvedValue([
'server',
'redis',
'version',
'7.4.0',
'modules',
[
['name', 'timeseries', 'ver', 11000],
['name', 'search', 'ver', 21000],
],
]);

const result = await service.determineDatabaseModules(standaloneClient);

expect(standaloneClient.call).toHaveBeenCalledWith(
['hello'],
expect.anything(),
);
expect(result).toEqual([
{
name: AdditionalRedisModuleName.RedisTimeSeries,
version: 11000,
semanticVersion: '1.10.0',
},
{
name: AdditionalRedisModuleName.RediSearch,
version: 21000,
semanticVersion: '2.10.0',
},
]);
});
});

describe('determineDatabaseServer', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ export class DatabaseInfoProvider {
: undefined,
}));
} catch (e) {
return this.determineDatabaseModulesUsingInfo(client);
const fromCommands = await this.determineDatabaseModulesUsingInfo(client);
if (fromCommands.length) {
return fromCommands;
}
return this.determineDatabaseModulesUsingHelloOrInfo(client);
}
}

Expand Down Expand Up @@ -131,6 +135,98 @@ export class DatabaseInfoProvider {
);
}

/**
* Determine database modules from HELLO or INFO response when MODULE LIST
* and COMMAND INFO are unavailable (e.g. restricted ACL users).
* Prefer HELLO: getInfo()'s INFO parser collapses duplicate `module:` lines into
* a single { module: "name=...,ver=..." } entry, which is not a usable module list.
* @param client
* @private
*/
public async determineDatabaseModulesUsingHelloOrInfo(
client: RedisClient,
): Promise<AdditionalRedisModule[]> {
try {
let rawModules = await this.getModulesFromHello(client);

if (!rawModules.length) {
try {
const info = await client.getInfo();
rawModules = this.normalizeModulesFromInfo(info?.modules);
} catch {
// INFO may be denied
}
}

if (!rawModules.length) {
return [];
}

const modules = await this.filterRawModules(
client.clientMetadata.sessionMetadata,
rawModules,
);

return modules.map(({ name, ver }) => ({
name: SUPPORTED_REDIS_MODULES[name] ?? name,
version: ver,
semanticVersion: SUPPORTED_REDIS_MODULES[name]
? convertIntToSemanticVersion(ver)
: undefined,
}));
} catch (e) {
return [];
}
}

private async getModulesFromHello(
client: RedisClient,
): Promise<{ name: string; ver?: number }[]> {
try {
const helloResponse = (await client.call(['hello'], {
replyEncoding: 'utf8',
})) as string[];
const helloInfo = convertArrayReplyToObject(helloResponse);
const modules = Array.isArray(helloInfo.modules)
? helloInfo.modules.map((module) =>
Array.isArray(module) ? convertArrayReplyToObject(module) : module,
)
: helloInfo.modules;

return this.normalizeModulesFromInfo(modules);
} catch {
return [];
}
}

private normalizeModulesFromInfo(
modules: unknown,
): { name: string; ver?: number }[] {
if (!modules) {
return [];
}
if (Array.isArray(modules)) {
return modules.filter(
(module) =>
module &&
typeof module === 'object' &&
typeof (module as { name?: unknown }).name === 'string',
) as { name: string; ver?: number }[];
}
if (typeof modules === 'object') {
const entries = Object.entries(modules as Record<string, unknown>);
// INFO # Modules collapses to { module: "name=...,ver=..." } — not usable
if (entries.some(([key]) => key === 'module')) {
return [];
Comment on lines +219 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse INFO module entries before returning none

When HELLO is blocked but INFO is allowed, Redis reports modules as module:name=timeseries,ver=... lines, and this repo's getInfo() parser collapses those into info.modules.module. This new branch discards that data entirely, so restricted ACL users in exactly that fallback path still get [] from determineDatabaseModulesUsingHelloOrInfo and Workbench continues to think TimeSeries is not loaded. Parse the collapsed module string (or fetch raw INFO modules) before giving up.

Useful? React with 👍 / 👎.

}
return entries.map(([name, ver]) => ({
name,
ver: parseInt(String(ver), 10) || undefined,
}));
}
return [];
}

public async getRedisDBSize(client: RedisClient): Promise<number> {
if (client.getConnectionType() === RedisClientConnectionType.CLUSTER) {
const nodesResult: number[] = await Promise.all(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,34 @@ describe('QueryCardCliGroupResult', () => {
expect(errorBtn).not.toBeInTheDocument()
expect(screen.getByText('(nil)')).toBeInTheDocument()
})

it('should not show ModuleNotLoaded for successful TS.RANGE in group mode', () => {
// Cast: group-mode response nests execution-like objects; matches existing
// fixtures in this file (baseline already has TS2322 on those).
const mockResult = [
{
response: [
{
id: 'id',
command: 'TS.RANGE ts:prices - +',
// Raw Redis reply shape that previously fooled the success check
response: [[1784245557285, '100']],
status: CommandExecutionStatus.Success,
},
],
status: CommandExecutionStatus.Success,
},
] as Props['result']
render(
<QueryCardCliGroupResult
{...instance(mockedProps)}
result={mockResult}
/>,
)

expect(
screen.queryByTestId('module-not-loaded-content'),
).not.toBeInTheDocument()
expect(screen.getByText(/TS\.RANGE ts:prices/)).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ const QueryCardCliGroupResult = (props: Props) => {
isFullScreen={isFullScreen}
items={flatten(
result?.[0]?.response.map((item: any) => {
const commonError = CommonErrorResponse(
item.id,
item.command,
item.response,
)
// Pass CommandExecutionResult shape so CommonErrorResponse can read
// item.status. Passing item.response alone makes successful array
// replies (e.g. TS.RANGE) look like failures under ACL (#5357).
const commonError = CommonErrorResponse(item.id, item.command, [
{ status: item.status, response: item.response },
])
if (React.isValidElement(commonError) && !isNull(item.response)) {
return [wbSummaryCommand(item.command), commonError]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,19 @@ const CommonErrorResponse = (id: string, command = '', result?: any) => {
CommandExecutionStatus.Fail,
)
}
const unsupportedModule = checkUnsupportedModuleCommand(modules, commandLine)

const isSuccessfulResult = Array.isArray(result)
? result.length > 0 &&
result.every((item) => item?.status === CommandExecutionStatus.Success)
: result?.status === CommandExecutionStatus.Success
Comment on lines +95 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use group item status to suppress module guidance

When Workbench renders grouped results, QueryCardCliGroupResult calls CommonErrorResponse(item.id, item.command, item.response), so this new success check sees the Redis reply itself rather than the item.status. For a successful module command whose reply is an array, such as TS.RANGE, result.every(item => item?.status === 'success') is false because the array entries are data points, so an ACL connection with an empty/unknown modules list still replaces the successful grouped reply with ModuleNotLoaded. Pass the grouped command status or treat the wrapper result consistently before running checkUnsupportedModuleCommand.

Useful? React with 👍 / 👎.


// Don't replace a successful reply with ModuleNotLoaded — under ACL, modules
// may be unknown even when the command ran fine (see #5357).
// Callers must pass CommandExecutionResult[] (including group mode), not the
// raw Redis reply, so status is visible here.
const unsupportedModule = !isSuccessfulResult
? checkUnsupportedModuleCommand(modules, commandLine)
: undefined

if (unsupportedModule) {
return <ModuleNotLoaded moduleName={unsupportedModule} id={id} />
Expand Down