From 4d400f7ca70867be03fd6d2646c0b5e914dfd347 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 16 Jul 2026 16:38:46 -0700 Subject: [PATCH 1/5] fix(workbench): do not block module commands when detection fails under ACL Skip the module gate when the command succeeded or modules are unknown, and fall back to HELLO/INFO module detection when MODULE LIST and COMMAND INFO are unavailable. Fixes #5357 --- .../api/src/constants/redis-modules.ts | 5 +- .../providers/database-info.provider.spec.ts | 35 +++++++++++- .../providers/database-info.provider.ts | 57 ++++++++++++++++++- .../CommonErrorResponse.tsx | 14 ++++- 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/src/constants/redis-modules.ts b/redisinsight/api/src/constants/redis-modules.ts index adeffddac1..bbd94ac8f7 100644 --- a/redisinsight/api/src/constants/redis-modules.ts +++ b/redisinsight/api/src/constants/redis-modules.ts @@ -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[] = [ diff --git a/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts b/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts index 9c95970426..587ce8243e 100644 --- a/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts +++ b/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts @@ -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); @@ -380,11 +380,44 @@ describe('DatabaseInfoProvider', () => { expect.anything(), ) .mockRejectedValue(mockUnknownCommandModule); + when(standaloneClient.getInfo).mockResolvedValue({}); const result = await service.determineDatabaseModules(standaloneClient); expect(result).toEqual([]); }); + it('should detect modules from HELLO/INFO 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.getInfo).mockResolvedValue({ + 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', + }, + ]); + }); }); describe('determineDatabaseServer', () => { diff --git a/redisinsight/api/src/modules/database/providers/database-info.provider.ts b/redisinsight/api/src/modules/database/providers/database-info.provider.ts index ce573a9e36..e2d0a145ae 100644 --- a/redisinsight/api/src/modules/database/providers/database-info.provider.ts +++ b/redisinsight/api/src/modules/database/providers/database-info.provider.ts @@ -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); } } @@ -131,6 +135,57 @@ export class DatabaseInfoProvider { ); } + /** + * Determine database modules from HELLO or INFO response when MODULE LIST + * and COMMAND INFO are unavailable (e.g. restricted ACL users) + * @param client + * @private + */ + public async determineDatabaseModulesUsingHelloOrInfo( + client: RedisClient, + ): Promise { + try { + const info = await client.getInfo(); + const rawModules = this.normalizeModulesFromInfo(info?.modules); + 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 normalizeModulesFromInfo(modules: unknown): { name: string; ver?: number }[] { + if (!modules) { + return []; + } + if (Array.isArray(modules)) { + return modules; + } + if (typeof modules === 'object') { + return Object.entries(modules as Record).map( + ([name, ver]) => ({ + name, + ver: parseInt(String(ver), 10) || undefined, + }), + ); + } + return []; + } + public async getRedisDBSize(client: RedisClient): Promise { if (client.getConnectionType() === RedisClientConnectionType.CLUSTER) { const nodesResult: number[] = await Promise.all( diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx index 198b826504..0d255eb84c 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx @@ -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 + const modulesUnknown = !modules?.length + + const unsupportedModule = + !isSuccessfulResult && !modulesUnknown + ? checkUnsupportedModuleCommand(modules, commandLine) + : undefined if (unsupportedModule) { return From 6eed2d81544a168ccd9b02e559279432df49724e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 17 Jul 2026 14:37:43 -0700 Subject: [PATCH 2/5] fix(workbench): harden ACL module detection for TimeSeries Prefer HELLO for module discovery when MODULE LIST fails, and only suppress ModuleNotLoaded for successful command replies. --- .../providers/database-info.provider.spec.ts | 69 +++++++++++++++++-- .../providers/database-info.provider.ts | 61 +++++++++++++--- .../CommonErrorResponse.tsx | 10 +-- 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts b/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts index 587ce8243e..d99852b964 100644 --- a/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts +++ b/redisinsight/api/src/modules/database/providers/database-info.provider.spec.ts @@ -380,13 +380,55 @@ 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/INFO when MODULE LIST and COMMAND INFO are not allowed', async () => { + 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); @@ -396,15 +438,32 @@ describe('DatabaseInfoProvider', () => { expect.anything(), ) .mockRejectedValue(mockUnknownCommandModule); + // INFO # Modules collapses duplicate keys to a single unusable entry when(standaloneClient.getInfo).mockResolvedValue({ - modules: [ - { name: 'timeseries', ver: 11000 }, - { name: 'search', ver: 21000 }, - ], + 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, diff --git a/redisinsight/api/src/modules/database/providers/database-info.provider.ts b/redisinsight/api/src/modules/database/providers/database-info.provider.ts index e2d0a145ae..1f336cbec7 100644 --- a/redisinsight/api/src/modules/database/providers/database-info.provider.ts +++ b/redisinsight/api/src/modules/database/providers/database-info.provider.ts @@ -137,7 +137,9 @@ export class DatabaseInfoProvider { /** * Determine database modules from HELLO or INFO response when MODULE LIST - * and COMMAND INFO are unavailable (e.g. restricted ACL users) + * 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 */ @@ -145,8 +147,17 @@ export class DatabaseInfoProvider { client: RedisClient, ): Promise { try { - const info = await client.getInfo(); - const rawModules = this.normalizeModulesFromInfo(info?.modules); + 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 []; } @@ -168,20 +179,50 @@ export class DatabaseInfoProvider { } } + 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; + return modules.filter( + (module) => + module && + typeof module === 'object' && + typeof (module as { name?: unknown }).name === 'string', + ) as { name: string; ver?: number }[]; } if (typeof modules === 'object') { - return Object.entries(modules as Record).map( - ([name, ver]) => ({ - name, - ver: parseInt(String(ver), 10) || undefined, - }), - ); + const entries = Object.entries(modules as Record); + // INFO # Modules collapses to { module: "name=...,ver=..." } — not usable + if (entries.some(([key]) => key === 'module')) { + return []; + } + return entries.map(([name, ver]) => ({ + name, + ver: parseInt(String(ver), 10) || undefined, + })); } return []; } diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx index 0d255eb84c..cee01d94fc 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx @@ -98,12 +98,12 @@ const CommonErrorResponse = (id: string, command = '', result?: any) => { (item) => item?.status === CommandExecutionStatus.Success, ) : result?.status === CommandExecutionStatus.Success - const modulesUnknown = !modules?.length - const unsupportedModule = - !isSuccessfulResult && !modulesUnknown - ? checkUnsupportedModuleCommand(modules, commandLine) - : undefined + // Don't replace a successful reply with ModuleNotLoaded — under ACL, modules + // may be unknown even when the command ran fine (see #5357). + const unsupportedModule = !isSuccessfulResult + ? checkUnsupportedModuleCommand(modules, commandLine) + : undefined if (unsupportedModule) { return From 9fa67d8c5afbafa13969327616655c6ffe6735ee Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 20 Jul 2026 09:51:51 -0700 Subject: [PATCH 3/5] fix(workbench): pass group-mode status into ModuleNotLoaded check Grouped results were handing CommonErrorResponse the raw Redis reply, so successful TS.RANGE arrays looked like failures under ACL. Pass the CommandExecutionResult wrapper so item.status is visible. --- .../QueryCardCliGroupResult.spec.tsx | 19 ++++++++++--------- .../QueryCardCliGroupResult.tsx | 11 ++++++----- .../CommonErrorResponse.tsx | 2 ++ 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx index 089ff006e2..01407167d9 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx @@ -61,30 +61,31 @@ describe('QueryCardCliGroupResult', () => { expect(errorBtn).toBeInTheDocument() }) - it('should render (nil) when response is null', () => { + it('should not show ModuleNotLoaded for successful TS.RANGE in group mode', () => { const mockResult = [ { response: [ { id: 'id', - command: 'psubscribe', - response: null, + command: 'TS.RANGE ts:prices - +', + // Raw Redis reply shape that previously fooled the success check + response: [[1784245557285, '100']], status: CommandExecutionStatus.Success, }, ], + status: CommandExecutionStatus.Success, }, ] - const { container } = render( + render( , ) - const errorBtn = container.querySelector( - '[data-test-subj="pubsub-page-btn"]', - ) - expect(errorBtn).not.toBeInTheDocument() - expect(screen.getByText('(nil)')).toBeInTheDocument() + expect( + screen.queryByTestId('module-not-loaded-content'), + ).not.toBeInTheDocument() + expect(screen.getByText(/TS\.RANGE ts:prices/)).toBeInTheDocument() }) }) diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx index f2d1173600..094a5ca4d0 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx @@ -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] } diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx index cee01d94fc..2ecfad60d6 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx @@ -101,6 +101,8 @@ const CommonErrorResponse = (id: string, command = '', result?: any) => { // 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 From ef08c14f27195b39a52a423263f0be7c85e072d1 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 20 Jul 2026 09:52:39 -0700 Subject: [PATCH 4/5] test(workbench): restore nil group-result case dropped in prior commit --- .../QueryCardCliGroupResult.spec.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx index 01407167d9..0cd77f4a0a 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx @@ -61,6 +61,33 @@ describe('QueryCardCliGroupResult', () => { expect(errorBtn).toBeInTheDocument() }) + it('should render (nil) when response is null', () => { + const mockResult = [ + { + response: [ + { + id: 'id', + command: 'psubscribe', + response: null, + status: CommandExecutionStatus.Success, + }, + ], + }, + ] + const { container } = render( + , + ) + const errorBtn = container.querySelector( + '[data-test-subj="pubsub-page-btn"]', + ) + + expect(errorBtn).not.toBeInTheDocument() + expect(screen.getByText('(nil)')).toBeInTheDocument() + }) + it('should not show ModuleNotLoaded for successful TS.RANGE in group mode', () => { const mockResult = [ { From 69209942779f08a3498bac191e84ca0c7c09f4cb Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 27 Jul 2026 17:12:41 -0700 Subject: [PATCH 5/5] chore(workbench): fix eslint and tsc CI for ACL module detection Apply prettier formatting and assert group-mode fixture types so the PR does not introduce a new tsc baseline error. --- .../modules/database/providers/database-info.provider.ts | 8 ++++---- .../QueryCardCliGroupResult.spec.tsx | 4 +++- .../CommonErrorResponse/CommonErrorResponse.tsx | 4 +--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/redisinsight/api/src/modules/database/providers/database-info.provider.ts b/redisinsight/api/src/modules/database/providers/database-info.provider.ts index 1f336cbec7..a3a1ade484 100644 --- a/redisinsight/api/src/modules/database/providers/database-info.provider.ts +++ b/redisinsight/api/src/modules/database/providers/database-info.provider.ts @@ -189,9 +189,7 @@ export class DatabaseInfoProvider { const helloInfo = convertArrayReplyToObject(helloResponse); const modules = Array.isArray(helloInfo.modules) ? helloInfo.modules.map((module) => - Array.isArray(module) - ? convertArrayReplyToObject(module) - : module, + Array.isArray(module) ? convertArrayReplyToObject(module) : module, ) : helloInfo.modules; @@ -201,7 +199,9 @@ export class DatabaseInfoProvider { } } - private normalizeModulesFromInfo(modules: unknown): { name: string; ver?: number }[] { + private normalizeModulesFromInfo( + modules: unknown, + ): { name: string; ver?: number }[] { if (!modules) { return []; } diff --git a/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx index 0cd77f4a0a..72be83bfb1 100644 --- a/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx @@ -89,6 +89,8 @@ describe('QueryCardCliGroupResult', () => { }) 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: [ @@ -102,7 +104,7 @@ describe('QueryCardCliGroupResult', () => { ], status: CommandExecutionStatus.Success, }, - ] + ] as Props['result'] render( { const isSuccessfulResult = Array.isArray(result) ? result.length > 0 && - result.every( - (item) => item?.status === CommandExecutionStatus.Success, - ) + result.every((item) => item?.status === CommandExecutionStatus.Success) : result?.status === CommandExecutionStatus.Success // Don't replace a successful reply with ModuleNotLoaded — under ACL, modules