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..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 @@ -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,71 @@ 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('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 7b3b6d8d4e..dda3b166a5 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, + useQuery as useConnectQuery, + useMutation, + 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,70 @@ 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, 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; +// 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, - 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 || [], - }), + // 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 = ''; + 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, + }), + }); + const response = await callUnaryMethod(transport, listSecrets, request, { signal }); + for (const secret of response.response?.secrets ?? []) { + if (secret) { + secrets.push(secret); + } + } + const next = response.response?.nextPageToken ?? ''; + if (!next) { + 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; + } + throw new Error(`ListSecrets exceeded ${SECRETS_LIST_MAX_PAGES} pages; aborting to avoid runaway pagination`); + }, }); }; @@ -63,7 +104,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 +114,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, }); };