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
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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(<SecretsStoreListPage />, { 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(<SecretsStoreListPage />, { 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: {
Expand Down
89 changes: 65 additions & 24 deletions frontend/src/react-query/api/secret.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<ListSecretsRequestDataPlane>,
options?: QueryOptions<GenMessage<ListSecretsRequest>, 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`);
},
});
};

Expand All @@ -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 = (
Expand All @@ -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,
});
};
Expand Down
Loading