Skip to content

Commit 2f447d3

Browse files
committed
frontend: useKubeObject: Probe endpoints with GET-by-name instead of List
Updates the frontend Kubernetes API v2 endpoint resolution logic so useKubeObject can load single-resource detail views for users who have get RBAC permission but not list, by probing candidate endpoints with GET-by-name instead of collection LIST.
1 parent d36a68d commit 2f447d3

File tree

10 files changed

+402
-17
lines changed

10 files changed

+402
-17
lines changed

frontend/src/components/crd/CustomResourceDefinition.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export default {
3939
'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions',
4040
() => HttpResponse.error()
4141
),
42+
http.get(
43+
'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/mydefinition.phonyresources.io',
44+
() => HttpResponse.error()
45+
),
4246
http.get(
4347
'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
4448
() =>

frontend/src/components/crd/CustomResourceDetails.stories.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ export default {
3232
'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions',
3333
() => HttpResponse.error()
3434
),
35+
http.get(
36+
'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/mydefinition.phonyresources.io',
37+
() => HttpResponse.error()
38+
),
39+
http.get(
40+
'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/error.crd.io',
41+
() => HttpResponse.error()
42+
),
43+
http.get(
44+
'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/loadingcrd',
45+
() => HttpResponse.error()
46+
),
3547
http.get(
3648
'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
3749
() => HttpResponse.json({})

frontend/src/components/crd/CustomResourceList.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export default {
4646
'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions',
4747
() => HttpResponse.error()
4848
),
49+
http.get(
50+
'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/mydefinition.phonyresources.io',
51+
() => HttpResponse.error()
52+
),
4953
http.get(
5054
'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
5155
() =>

frontend/src/components/gateway/ClassDetails.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default {
4646
() => HttpResponse.error()
4747
),
4848
http.get(
49-
'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/gatewayclasses',
49+
'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/gatewayclasses/default-gateway-class',
5050
() => HttpResponse.error()
5151
),
5252
http.get('http://localhost:4466/api/v1/namespaces/default/events', () =>

frontend/src/components/gateway/GRPCRouteDetails.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export default {
4444
http.get('http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/grpcroutes', () =>
4545
HttpResponse.error()
4646
),
47+
http.get(
48+
'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/grpcroutes/default-grpcroute',
49+
() => HttpResponse.error()
50+
),
4751
http.get('http://localhost:4466/apis/gateway.networking.k8s.io/v1/grpcroutes', () =>
4852
HttpResponse.error()
4953
),

frontend/src/components/gateway/GatewayDetails.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export default {
4444
http.get('http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/gateways', () =>
4545
HttpResponse.error()
4646
),
47+
http.get(
48+
'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/gateways/default-gateway',
49+
() => HttpResponse.error()
50+
),
4751
http.get('http://localhost:4466/api/v1/namespaces/default/events', () =>
4852
HttpResponse.json({
4953
kind: 'EventList',

frontend/src/components/gateway/HTTPRouteDetails.stories.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export default {
4444
http.get('http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/httproutes', () =>
4545
HttpResponse.error()
4646
),
47+
http.get(
48+
'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/httproutes/default-httproute',
49+
() => HttpResponse.error()
50+
),
4751
http.get('http://localhost:4466/api/v1/namespaces/default/events', () =>
4852
HttpResponse.json({
4953
kind: 'EventList',

frontend/src/components/ingress/Details.stories.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export default {
4343
http.get('http://localhost:4466/apis/extensions/v1beta1/ingresses', () =>
4444
HttpResponse.error()
4545
),
46+
http.get('http://localhost:4466/apis/extensions/v1beta1/ingresses/my-ingress', () =>
47+
HttpResponse.error()
48+
),
4649
http.get('http://localhost:4466/api/v1/namespaces/default/events', () =>
4750
HttpResponse.json({
4851
kind: 'EventList',
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
18+
import { renderHook, waitFor } from '@testing-library/react';
19+
import type { ReactNode } from 'react';
20+
import { beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest';
21+
import { ApiError } from './ApiError';
22+
import { clusterFetch } from './fetch';
23+
import { useEndpoints } from './hooks';
24+
import type { KubeObjectEndpoint } from './KubeObjectEndpoint';
25+
26+
vi.mock('./fetch', () => ({
27+
clusterFetch: vi.fn(),
28+
}));
29+
30+
const mockClusterFetch = clusterFetch as MockedFunction<typeof clusterFetch>;
31+
32+
const mockJsonResponse = (data: unknown) =>
33+
({
34+
ok: true,
35+
status: 200,
36+
json: () => Promise.resolve(data),
37+
} as Response);
38+
39+
const createWrapper = () => {
40+
const queryClient = new QueryClient({
41+
defaultOptions: {
42+
queries: {
43+
retry: false,
44+
refetchOnWindowFocus: false,
45+
refetchOnMount: false,
46+
refetchOnReconnect: false,
47+
},
48+
},
49+
});
50+
51+
return ({ children }: { children: ReactNode }) => (
52+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
53+
);
54+
};
55+
56+
describe('useEndpoints', () => {
57+
beforeEach(() => {
58+
vi.clearAllMocks();
59+
});
60+
61+
it('returns a single endpoint without probing', () => {
62+
const endpoint: KubeObjectEndpoint = { version: 'v1', resource: 'pods' };
63+
64+
const { result } = renderHook(() => useEndpoints([endpoint], 'cluster-a', 'default', 'pod-a'), {
65+
wrapper: createWrapper(),
66+
});
67+
68+
expect(result.current.endpoint).toEqual(endpoint);
69+
expect(result.current.error).toBeNull();
70+
expect(mockClusterFetch).not.toHaveBeenCalled();
71+
});
72+
73+
it('uses name-based GET probing in endpoint priority order', async () => {
74+
const endpoints: KubeObjectEndpoint[] = [
75+
{ group: 'extensions', version: 'v1beta1', resource: 'ingresses' },
76+
{ group: 'networking.k8s.io', version: 'v1', resource: 'ingresses' },
77+
];
78+
79+
mockClusterFetch.mockImplementation(url => {
80+
if (url === 'apis/extensions/v1beta1/namespaces/default/ingresses/demo') {
81+
return Promise.reject(new ApiError('Not Found', { status: 404 }));
82+
}
83+
if (url === 'apis/networking.k8s.io/v1/namespaces/default/ingresses/demo') {
84+
return Promise.resolve(mockJsonResponse({}));
85+
}
86+
87+
return Promise.reject(new Error(`Unexpected URL: ${String(url)}`));
88+
});
89+
90+
const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default', 'demo'), {
91+
wrapper: createWrapper(),
92+
});
93+
94+
await waitFor(() => {
95+
expect(result.current.endpoint).toEqual(endpoints[1]);
96+
});
97+
98+
const calledUrls = mockClusterFetch.mock.calls.map(([url]) => String(url));
99+
expect(calledUrls).toEqual([
100+
'apis/extensions/v1beta1/namespaces/default/ingresses/demo',
101+
'apis/networking.k8s.io/v1/namespaces/default/ingresses/demo',
102+
]);
103+
expect(calledUrls).not.toContain('apis/extensions/v1beta1/namespaces/default/ingresses');
104+
});
105+
106+
it('handles cluster-scoped GET-by-name probing without namespace', async () => {
107+
const endpoints: KubeObjectEndpoint[] = [
108+
{
109+
group: 'gateway.networking.k8s.io',
110+
version: 'v1',
111+
resource: 'gatewayclasses',
112+
},
113+
];
114+
mockClusterFetch.mockImplementation(url => {
115+
if (url === 'apis/gateway.networking.k8s.io/v1/gatewayclasses/test-gc') {
116+
return Promise.resolve(
117+
mockJsonResponse({
118+
apiVersion: 'gateway.networking.k8s.io/v1',
119+
kind: 'GatewayClass',
120+
metadata: { name: 'test-gc' },
121+
})
122+
);
123+
}
124+
return Promise.reject(new Error(`Unexpected URL: ${String(url)}`));
125+
});
126+
127+
const { result } = renderHook(
128+
() => useEndpoints(endpoints, 'cluster-a', undefined, 'test-gc'),
129+
{
130+
wrapper: createWrapper(),
131+
}
132+
);
133+
134+
await waitFor(() => {
135+
expect(result.current.endpoint).toEqual(endpoints[0]);
136+
expect(result.current.error).toBeNull();
137+
});
138+
});
139+
140+
it('continues probing after forbidden and resolves next endpoint', async () => {
141+
const endpoints: KubeObjectEndpoint[] = [
142+
{ group: 'extensions', version: 'v1beta1', resource: 'ingresses' },
143+
{ group: 'networking.k8s.io', version: 'v1', resource: 'ingresses' },
144+
];
145+
146+
mockClusterFetch.mockImplementation(url => {
147+
if (url === 'apis/extensions/v1beta1/namespaces/default/ingresses/demo') {
148+
return Promise.reject(new ApiError('Forbidden', { status: 403 }));
149+
}
150+
if (url === 'apis/networking.k8s.io/v1/namespaces/default/ingresses/demo') {
151+
return Promise.resolve(mockJsonResponse({}));
152+
}
153+
154+
return Promise.reject(new Error(`Unexpected URL: ${String(url)}`));
155+
});
156+
157+
const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default', 'demo'), {
158+
wrapper: createWrapper(),
159+
});
160+
161+
await waitFor(() => {
162+
expect(result.current.endpoint).toEqual(endpoints[1]);
163+
});
164+
});
165+
166+
it('returns error when all get probes fail', async () => {
167+
const endpoints: KubeObjectEndpoint[] = [
168+
{ group: 'apps', version: 'v1', resource: 'deployments' },
169+
{ group: 'extensions', version: 'v1beta1', resource: 'deployments' },
170+
];
171+
172+
mockClusterFetch.mockImplementation(url => {
173+
if (url === 'apis/apps/v1/namespaces/default/deployments/demo') {
174+
return Promise.reject(new ApiError('Not Found', { status: 404 }));
175+
}
176+
if (url === 'apis/extensions/v1beta1/namespaces/default/deployments/demo') {
177+
return Promise.reject(new ApiError('Forbidden', { status: 403 }));
178+
}
179+
180+
return Promise.reject(new Error(`Unexpected URL: ${String(url)}`));
181+
});
182+
183+
const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default', 'demo'), {
184+
wrapper: createWrapper(),
185+
});
186+
187+
await waitFor(() => {
188+
expect(result.current.error).toBeTruthy();
189+
});
190+
expect(result.current.endpoint).toBeUndefined();
191+
});
192+
193+
it('keeps collection probing behavior when name is not provided', async () => {
194+
const endpoints: KubeObjectEndpoint[] = [
195+
{ group: 'batch', version: 'v1', resource: 'jobs' },
196+
{ group: 'batch', version: 'v1beta1', resource: 'jobs' },
197+
];
198+
199+
mockClusterFetch.mockImplementation(url => {
200+
if (url === 'apis/batch/v1/namespaces/default/jobs') {
201+
return Promise.reject(new ApiError('Forbidden', { status: 403 }));
202+
}
203+
if (url === 'apis/batch/v1beta1/namespaces/default/jobs') {
204+
return Promise.resolve(mockJsonResponse({}));
205+
}
206+
207+
return Promise.reject(new Error(`Unexpected URL: ${String(url)}`));
208+
});
209+
210+
const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default'), {
211+
wrapper: createWrapper(),
212+
});
213+
214+
await waitFor(() => {
215+
expect(result.current.endpoint).toEqual(endpoints[1]);
216+
});
217+
218+
const calledUrls = mockClusterFetch.mock.calls.map(([url]) => String(url));
219+
expect(calledUrls).toEqual([
220+
'apis/batch/v1/namespaces/default/jobs',
221+
'apis/batch/v1beta1/namespaces/default/jobs',
222+
]);
223+
});
224+
225+
it('returns error when all list probes fail', async () => {
226+
const endpoints: KubeObjectEndpoint[] = [
227+
{ group: 'batch', version: 'v1', resource: 'jobs' },
228+
{ group: 'batch', version: 'v1beta1', resource: 'jobs' },
229+
];
230+
231+
mockClusterFetch.mockImplementation(url => {
232+
if (url === 'apis/batch/v1/namespaces/default/jobs') {
233+
return Promise.reject(new ApiError('Not Found', { status: 404 }));
234+
}
235+
if (url === 'apis/batch/v1beta1/namespaces/default/jobs') {
236+
return Promise.reject(new ApiError('Forbidden', { status: 403 }));
237+
}
238+
239+
return Promise.reject(new Error(`Unexpected URL: ${String(url)}`));
240+
});
241+
242+
const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default'), {
243+
wrapper: createWrapper(),
244+
});
245+
246+
await waitFor(() => {
247+
expect(result.current.error).toBeTruthy();
248+
});
249+
expect(result.current.endpoint).toBeUndefined();
250+
});
251+
252+
it('handles non-ok response in list probing', async () => {
253+
const endpoints: KubeObjectEndpoint[] = [
254+
{ group: 'batch', version: 'v1', resource: 'jobs' },
255+
{ group: 'batch', version: 'v1beta1', resource: 'jobs' },
256+
];
257+
258+
mockClusterFetch.mockImplementation(url => {
259+
if (url === 'apis/batch/v1/namespaces/default/jobs') {
260+
return Promise.reject(new ApiError('Forbidden', { status: 403 }));
261+
}
262+
if (url === 'apis/batch/v1beta1/namespaces/default/jobs') {
263+
return Promise.resolve(mockJsonResponse({}));
264+
}
265+
266+
return Promise.reject(new Error(`Unexpected URL: ${String(url)}`));
267+
});
268+
269+
const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default'), {
270+
wrapper: createWrapper(),
271+
});
272+
273+
await waitFor(() => {
274+
expect(result.current.endpoint).toEqual(endpoints[1]);
275+
});
276+
});
277+
});

0 commit comments

Comments
 (0)