From d9778ea7daccc0abc2d7a32e12053fa14e50219f Mon Sep 17 00:00:00 2001 From: Beniamin Malinski Date: Mon, 20 Apr 2026 19:09:05 +0200 Subject: [PATCH 1/4] fix(secrets): paginate ListSecrets to return all entries useListSecretsQuery sent a single request with pageSize=25 and dropped everything past the first page, silently hiding secrets (e.g. PGDB_DSN in accounts with >25 secrets). Loop nextPageToken via callUnaryMethod until exhausted and aggregate into a single result, at the server-side max of 50 per page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../secrets-store-list-page.test.tsx | 43 ++++++++++- frontend/src/react-query/api/secret.tsx | 77 +++++++++++++------ 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx b/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx index 54954a894a..3b272243f1 100644 --- a/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx +++ b/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx @@ -16,7 +16,7 @@ import { ListSecretsResponseSchema } from 'protogen/redpanda/api/console/v1alpha import { listSecrets } from 'protogen/redpanda/api/console/v1alpha1/secret-SecretService_connectquery'; import { Scope, SecretSchema } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; import React from 'react'; -import { MAX_PAGE_SIZE } from 'react-query/react-query.utils'; +import { SECRETS_LIST_PAGE_SIZE } from 'react-query/api/secret'; import { renderWithFileRoutes, screen, waitFor } from 'test-utils'; vi.mock('state/ui-state', () => ({ @@ -81,11 +81,50 @@ describe('SecretsStoreListPage', () => { const callArgs = listSecretsMock.mock.calls[0]; expect(callArgs[0]).toMatchObject({ request: { - pageSize: MAX_PAGE_SIZE, + pageSize: SECRETS_LIST_PAGE_SIZE, + pageToken: '', }, }); }); + test('follows nextPageToken until all secrets are returned', async () => { + const pageOne = [ + create(SecretSchema, { + id: 'page1-first-secret', + labels: {}, + scopes: [Scope.AI_GATEWAY], + }), + ]; + const pageTwo = [ + create(SecretSchema, { + id: 'page2-pgdb-dsn', + labels: {}, + scopes: [Scope.AI_GATEWAY], + }), + ]; + + const listSecretsMock = vi.fn().mockImplementation(({ request }) => { + if (!request?.pageToken) { + return create(ListSecretsResponseSchema, { + response: { secrets: pageOne, nextPageToken: 'page2' }, + }); + } + return create(ListSecretsResponseSchema, { + response: { secrets: pageTwo, nextPageToken: '' }, + }); + }); + const transport = createListSecretsTransport(listSecretsMock); + + renderWithFileRoutes(, { transport }); + + expect(await screen.findByText('page1-first-secret')).toBeVisible(); + expect(await screen.findByText('page2-pgdb-dsn')).toBeVisible(); + expect(listSecretsMock).toHaveBeenCalledTimes(2); + expect(listSecretsMock.mock.calls[1][0]).toMatchObject({ + request: { pageSize: SECRETS_LIST_PAGE_SIZE, pageToken: 'page2' }, + }); + }); + test('should display empty state when no secrets exist', async () => { const listSecretsResponse = create(ListSecretsResponseSchema, { response: { diff --git a/frontend/src/react-query/api/secret.tsx b/frontend/src/react-query/api/secret.tsx index 7b3b6d8d4e..76e875733b 100644 --- a/frontend/src/react-query/api/secret.tsx +++ b/frontend/src/react-query/api/secret.tsx @@ -1,13 +1,17 @@ import { create } from '@bufbuild/protobuf'; import type { GenMessage } from '@bufbuild/protobuf/codegenv1'; -import { createConnectQueryKey, useMutation, useQuery } from '@connectrpc/connect-query'; -import { useQueryClient } from '@tanstack/react-query'; +import { + callUnaryMethod, + createConnectQueryKey, + useMutation, + useQuery as useConnectQuery, + useTransport, +} from '@connectrpc/connect-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { GetSecretRequestSchema, type GetSecretResponse, - type ListSecretsRequest, ListSecretsRequestSchema, - type ListSecretsResponse, SecretService, } from 'protogen/redpanda/api/console/v1alpha1/secret_pb'; import { @@ -26,33 +30,58 @@ import { ListSecretsFilterSchema, type ListSecretsRequest as ListSecretsRequestDataPlane, ListSecretsRequestSchema as ListSecretsRequestSchemaDataPlane, + type Secret, } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; import { listResources } from 'protogen/redpanda/api/dataplane/v1/secret-SecretService_connectquery'; -import { MAX_PAGE_SIZE, type MessageInit, type QueryOptions } from 'react-query/react-query.utils'; +import { type MessageInit, type QueryOptions } from 'react-query/react-query.utils'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; +// Matches the server-side upper bound declared in redpanda/api/dataplane/v1/secret.proto. +export const SECRETS_LIST_PAGE_SIZE = 50; + export const useListSecretsQuery = ( input?: MessageInit, - options?: QueryOptions, ListSecretsResponse> + options?: { enabled?: boolean } ) => { - const listSecretsRequestDataPlane = create(ListSecretsRequestSchemaDataPlane, { - pageSize: MAX_PAGE_SIZE, - filter: input?.filter?.nameContains - ? create(ListSecretsFilterSchema, { - nameContains: input?.filter?.nameContains, - }) - : undefined, - }); - - const listSecretsRequest = create(ListSecretsRequestSchema, { - request: listSecretsRequestDataPlane, - }); + const transport = useTransport(); + const nameContains = input?.filter?.nameContains; - return useQuery(listSecrets, listSecretsRequest, { + return useQuery({ + queryKey: [ + ...createConnectQueryKey({ + schema: SecretService.method.listSecrets, + cardinality: 'finite', + }), + { nameContains: nameContains ?? '' }, + ], enabled: options?.enabled, - select: (data) => ({ - secrets: data.response?.secrets || [], - }), + queryFn: async () => { + const secrets: Secret[] = []; + let pageToken = ''; + for (;;) { + const request = create(ListSecretsRequestSchema, { + request: create(ListSecretsRequestSchemaDataPlane, { + pageSize: SECRETS_LIST_PAGE_SIZE, + pageToken, + filter: nameContains + ? create(ListSecretsFilterSchema, { nameContains }) + : undefined, + }), + }); + const response = await callUnaryMethod(transport, listSecrets, request); + for (const secret of response.response?.secrets ?? []) { + if (secret) { + secrets.push(secret); + } + } + const next = response.response?.nextPageToken ?? ''; + if (!next) { + break; + } + pageToken = next; + } + return { secrets }; + }, }); }; @@ -63,7 +92,7 @@ export const useGetSecretQuery = ( const getSecretRequestDataPlane = create(GetSecretRequestSchemaDataPlane, { id: input?.id }); const getSecretRequest = create(GetSecretRequestSchema, { request: getSecretRequestDataPlane }); - return useQuery(getSecret, getSecretRequest, { enabled: options?.enabled }); + return useConnectQuery(getSecret, getSecretRequest, { enabled: options?.enabled }); }; export const useListResourcesForSecretQuery = ( @@ -73,7 +102,7 @@ export const useListResourcesForSecretQuery = ( const filter = create(ListResourcesRequest_FilterSchema, { secretId }); const request = create(ListResourcesRequestSchema, { filter }); - return useQuery(listResources, request, { + return useConnectQuery(listResources, request, { enabled: !!secretId && options?.enabled !== false, }); }; From 0286b0f655fd855e1acae9dbc7c76a56ba064333 Mon Sep 17 00:00:00 2001 From: Beniamin Malinski Date: Mon, 20 Apr 2026 19:10:58 +0200 Subject: [PATCH 2/4] fix(secrets): guard ListSecrets pagination against runaway loops Add a hard cap of 200 iterations and a same-token guard so a misbehaving server cannot trap the client in an infinite pagination loop. Also honor react-query's AbortSignal so in-flight pages cancel on unmount or refetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../secrets-store-list-page.test.tsx | 21 ++++++++++++++++ frontend/src/react-query/api/secret.tsx | 25 +++++++++++++------ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx b/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx index 3b272243f1..c103ad87cb 100644 --- a/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx +++ b/frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx @@ -125,6 +125,27 @@ describe('SecretsStoreListPage', () => { }); }); + test('stops and surfaces an error if the server returns a non-advancing pageToken', async () => { + const listSecretsMock = vi.fn().mockImplementation(() => + create(ListSecretsResponseSchema, { + response: { + secrets: [create(SecretSchema, { id: 'looping-secret', scopes: [Scope.AI_GATEWAY] })], + nextPageToken: 'stuck', + }, + }) + ); + const transport = createListSecretsTransport(listSecretsMock); + + renderWithFileRoutes(, { transport }); + + await waitFor(() => { + expect(screen.getByText(/Error loading secrets:/i)).toBeVisible(); + }); + // Server returned nextPageToken='stuck' on first call; second call echoed 'stuck' again and the + // loop bailed out. Anything higher means the guard did not trip. + expect(listSecretsMock).toHaveBeenCalledTimes(2); + }); + test('should display empty state when no secrets exist', async () => { const listSecretsResponse = create(ListSecretsResponseSchema, { response: { diff --git a/frontend/src/react-query/api/secret.tsx b/frontend/src/react-query/api/secret.tsx index 76e875733b..412f3ceef5 100644 --- a/frontend/src/react-query/api/secret.tsx +++ b/frontend/src/react-query/api/secret.tsx @@ -38,6 +38,10 @@ import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; // Matches the server-side upper bound declared in redpanda/api/dataplane/v1/secret.proto. export const SECRETS_LIST_PAGE_SIZE = 50; +// Hard cap on pagination iterations. At SECRETS_LIST_PAGE_SIZE=50 this allows 10k secrets, +// which is far beyond any realistic tenant. Protects against a misbehaving server returning +// non-empty nextPageToken indefinitely. +export const SECRETS_LIST_MAX_PAGES = 200; export const useListSecretsQuery = ( input?: MessageInit, @@ -55,20 +59,21 @@ export const useListSecretsQuery = ( { nameContains: nameContains ?? '' }, ], enabled: options?.enabled, - queryFn: async () => { + queryFn: async ({ signal }) => { const secrets: Secret[] = []; let pageToken = ''; - for (;;) { + for (let iteration = 0; iteration < SECRETS_LIST_MAX_PAGES; iteration++) { + if (signal?.aborted) { + throw signal.reason ?? new Error('ListSecrets query aborted'); + } const request = create(ListSecretsRequestSchema, { request: create(ListSecretsRequestSchemaDataPlane, { pageSize: SECRETS_LIST_PAGE_SIZE, pageToken, - filter: nameContains - ? create(ListSecretsFilterSchema, { nameContains }) - : undefined, + filter: nameContains ? create(ListSecretsFilterSchema, { nameContains }) : undefined, }), }); - const response = await callUnaryMethod(transport, listSecrets, request); + const response = await callUnaryMethod(transport, listSecrets, request, { signal }); for (const secret of response.response?.secrets ?? []) { if (secret) { secrets.push(secret); @@ -76,11 +81,15 @@ export const useListSecretsQuery = ( } const next = response.response?.nextPageToken ?? ''; if (!next) { - break; + return { secrets }; + } + // Guard against a server that returns the same token twice — would otherwise loop forever. + if (next === pageToken) { + throw new Error('ListSecrets returned a non-advancing nextPageToken; aborting to avoid infinite loop'); } pageToken = next; } - return { secrets }; + throw new Error(`ListSecrets exceeded ${SECRETS_LIST_MAX_PAGES} pages; aborting to avoid runaway pagination`); }, }); }; From 8e02c838531275a31f26b6c2733c79b9bfa9b4ed Mon Sep 17 00:00:00 2001 From: Beniamin Malinski Date: Mon, 20 Apr 2026 19:23:24 +0200 Subject: [PATCH 3/4] style(secrets): apply biome import ordering Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/react-query/api/secret.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/react-query/api/secret.tsx b/frontend/src/react-query/api/secret.tsx index 412f3ceef5..dfa1a9fd2c 100644 --- a/frontend/src/react-query/api/secret.tsx +++ b/frontend/src/react-query/api/secret.tsx @@ -3,8 +3,8 @@ import type { GenMessage } from '@bufbuild/protobuf/codegenv1'; import { callUnaryMethod, createConnectQueryKey, - useMutation, useQuery as useConnectQuery, + useMutation, useTransport, } from '@connectrpc/connect-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -33,7 +33,7 @@ import { type Secret, } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; import { listResources } from 'protogen/redpanda/api/dataplane/v1/secret-SecretService_connectquery'; -import { type MessageInit, type QueryOptions } from 'react-query/react-query.utils'; +import type { MessageInit, QueryOptions } from 'react-query/react-query.utils'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; // Matches the server-side upper bound declared in redpanda/api/dataplane/v1/secret.proto. From 7dbcc64eb674c439e693cea538fc535ee6888e9b Mon Sep 17 00:00:00 2001 From: Beniamin Malinski Date: Mon, 20 Apr 2026 19:24:17 +0200 Subject: [PATCH 4/4] fix(secrets): disable react-query retries on pagination errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queryFn throws intentionally on guard violations (non-advancing token, max-pages exceeded). React Query's default 3-retry policy would otherwise multiply the already-bounded runaway-pagination traffic by 4× before surfacing the failure. Addresses PR review feedback on #2394. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/react-query/api/secret.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/react-query/api/secret.tsx b/frontend/src/react-query/api/secret.tsx index dfa1a9fd2c..dda3b166a5 100644 --- a/frontend/src/react-query/api/secret.tsx +++ b/frontend/src/react-query/api/secret.tsx @@ -59,6 +59,9 @@ export const useListSecretsQuery = ( { nameContains: nameContains ?? '' }, ], enabled: options?.enabled, + // Our queryFn throws intentionally on pagination safety violations (non-advancing token, + // max-pages exceeded). Retrying would multiply the server round-trips for no benefit. + retry: false, queryFn: async ({ signal }) => { const secrets: Secret[] = []; let pageToken = '';