Skip to content

Commit 89fae28

Browse files
fix(frontend): repair security role edit route (#2332)
* fix(security): repair role edit route * test(security): assert role edit replace
1 parent b061884 commit 89fae28

6 files changed

Lines changed: 490 additions & 2 deletions

File tree

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
/**
2+
* Copyright 2026 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with
8+
* the Business Source License, use of this software will be governed
9+
* by the Apache License, Version 2.0
10+
*/
11+
12+
import { render, screen } from '@testing-library/react';
13+
import userEvent from '@testing-library/user-event';
14+
import type { ReactNode } from 'react';
15+
import { beforeEach, describe, expect, test, vi } from 'vitest';
16+
17+
const { historyPushMock, refreshRoleMembersMock, refreshRolesMock } = vi.hoisted(() => ({
18+
historyPushMock: vi.fn(),
19+
refreshRoleMembersMock: vi.fn().mockResolvedValue(undefined),
20+
refreshRolesMock: vi.fn().mockResolvedValue(undefined),
21+
}));
22+
23+
vi.mock('@redpanda-data/ui', () => {
24+
const Div = ({
25+
children,
26+
flexDirection: _flexDirection,
27+
...props
28+
}: {
29+
children?: ReactNode;
30+
flexDirection?: unknown;
31+
[key: string]: unknown;
32+
}) => <div {...props}>{children}</div>;
33+
34+
return {
35+
Alert: Div,
36+
AlertDescription: Div,
37+
AlertIcon: () => <span />,
38+
AlertTitle: Div,
39+
Badge: Div,
40+
Box: Div,
41+
Button: ({
42+
children,
43+
isDisabled,
44+
onClick,
45+
tooltip: _tooltip,
46+
...props
47+
}: {
48+
children?: ReactNode;
49+
isDisabled?: boolean;
50+
onClick?: () => void;
51+
tooltip?: unknown;
52+
[key: string]: unknown;
53+
}) => (
54+
<button disabled={isDisabled} onClick={onClick} {...props}>
55+
{children}
56+
</button>
57+
),
58+
CloseButton: ({
59+
children,
60+
onClick,
61+
...props
62+
}: {
63+
children?: ReactNode;
64+
onClick?: () => void;
65+
[key: string]: unknown;
66+
}) => (
67+
<button onClick={onClick} {...props}>
68+
{children}
69+
</button>
70+
),
71+
createStandaloneToast: () => ({
72+
ToastContainer: () => null,
73+
toast: vi.fn(),
74+
}),
75+
DataTable: ({
76+
columns,
77+
data,
78+
emptyAction,
79+
emptyText,
80+
}: {
81+
columns: Array<{
82+
cell?: (ctx: { row: { original: Record<string, unknown> } }) => ReactNode;
83+
header?: ReactNode;
84+
id: string;
85+
}>;
86+
data: Record<string, unknown>[];
87+
emptyAction?: ReactNode;
88+
emptyText?: ReactNode;
89+
}) =>
90+
data.length > 0 ? (
91+
<table>
92+
<tbody>
93+
{data.map((row, rowIndex) => (
94+
<tr key={String(row.name ?? rowIndex)}>
95+
{columns.map((column) => (
96+
<td key={column.id}>{column.cell?.({ row: { original: row } }) ?? null}</td>
97+
))}
98+
</tr>
99+
))}
100+
</tbody>
101+
</table>
102+
) : (
103+
<div>
104+
<div>{emptyText}</div>
105+
{emptyAction}
106+
</div>
107+
),
108+
Flex: Div,
109+
Icon: () => <span />,
110+
Link: ({
111+
as: Component,
112+
children,
113+
...props
114+
}: {
115+
as?: ((props: Record<string, unknown>) => ReactNode) | string;
116+
children?: ReactNode;
117+
[key: string]: unknown;
118+
}) =>
119+
Component && typeof Component !== 'string' ? (
120+
<Component {...props}>{children}</Component>
121+
) : (
122+
<a {...props}>{children}</a>
123+
),
124+
Menu: Div,
125+
MenuButton: ({
126+
children,
127+
onClick,
128+
...props
129+
}: {
130+
children?: ReactNode;
131+
onClick?: () => void;
132+
[key: string]: unknown;
133+
}) => (
134+
<button onClick={onClick} {...props}>
135+
{children}
136+
</button>
137+
),
138+
MenuItem: ({
139+
children,
140+
onClick,
141+
...props
142+
}: {
143+
children?: ReactNode;
144+
onClick?: () => void;
145+
[key: string]: unknown;
146+
}) => (
147+
<button onClick={onClick} {...props}>
148+
{children}
149+
</button>
150+
),
151+
MenuList: Div,
152+
redpandaTheme: {},
153+
redpandaToastOptions: {
154+
defaultOptions: {},
155+
},
156+
SearchField: ({
157+
placeholderText,
158+
searchText,
159+
setSearchText,
160+
...props
161+
}: {
162+
placeholderText?: string;
163+
searchText?: string;
164+
setSearchText?: (value: string) => void;
165+
[key: string]: unknown;
166+
}) => (
167+
<input
168+
onChange={(e) => setSearchText?.(e.target.value)}
169+
placeholder={placeholderText}
170+
value={searchText ?? ''}
171+
{...props}
172+
/>
173+
),
174+
Skeleton: Div,
175+
Tabs: ({ index = 0, items }: { index?: number; items: Array<{ component: ReactNode }> }) => (
176+
<div>{items[index]?.component ?? null}</div>
177+
),
178+
Text: Div,
179+
Tooltip: ({ children }: { children?: ReactNode }) => <>{children}</>,
180+
};
181+
});
182+
183+
vi.mock('@tanstack/react-router', async (importOriginal) => {
184+
const actual = await importOriginal<typeof import('@tanstack/react-router')>();
185+
186+
return {
187+
...actual,
188+
Link: ({
189+
children,
190+
params: _params,
191+
search: _search,
192+
to,
193+
...props
194+
}: {
195+
children: ReactNode;
196+
params?: unknown;
197+
search?: unknown;
198+
to?: string;
199+
[key: string]: unknown;
200+
}) => (
201+
<a href={to ?? ''} {...props}>
202+
{children}
203+
</a>
204+
),
205+
useNavigate: () => vi.fn(),
206+
};
207+
});
208+
209+
vi.mock('config', async (importOriginal) => {
210+
const actual = await importOriginal<typeof import('config')>();
211+
212+
return {
213+
...actual,
214+
isServerless: () => false,
215+
};
216+
});
217+
218+
vi.mock('./delete-role-confirm-modal', () => ({
219+
DeleteRoleConfirmModal: ({ buttonEl }: { buttonEl: ReactNode }) => <>{buttonEl}</>,
220+
}));
221+
222+
vi.mock('./delete-user-confirm-modal', () => ({
223+
DeleteUserConfirmModal: ({ buttonEl }: { buttonEl: ReactNode }) => <>{buttonEl}</>,
224+
}));
225+
226+
vi.mock('./models', () => ({
227+
createEmptyClusterAcl: vi.fn(),
228+
createEmptyConsumerGroupAcl: vi.fn(),
229+
createEmptyTopicAcl: vi.fn(),
230+
createEmptyTransactionalIdAcl: vi.fn(),
231+
principalGroupsView: {
232+
principalGroups: [],
233+
},
234+
}));
235+
236+
vi.mock('./principal-group-editor', () => ({
237+
AclPrincipalGroupEditor: () => null,
238+
}));
239+
240+
vi.mock('./user-edit-modals', () => ({
241+
ChangePasswordModal: () => null,
242+
ChangeRolesModal: () => null,
243+
}));
244+
245+
vi.mock('./user-permission-assignments', () => ({
246+
UserRoleTags: () => null,
247+
}));
248+
249+
vi.mock('../../../components/misc/error-result', () => ({
250+
default: () => null,
251+
}));
252+
253+
vi.mock('../../../state/app-global', () => ({
254+
appGlobal: {
255+
historyPush: historyPushMock,
256+
onRefresh: null,
257+
},
258+
}));
259+
260+
vi.mock('../../../state/backend-api', () => ({
261+
api: {
262+
ACLs: {
263+
isAuthorizerEnabled: true,
264+
},
265+
refreshClusterOverview: vi.fn().mockResolvedValue(undefined),
266+
refreshUserData: vi.fn().mockResolvedValue(undefined),
267+
userData: {
268+
canCreateRoles: true,
269+
canListAcls: true,
270+
canManageUsers: true,
271+
canViewPermissionsList: true,
272+
},
273+
},
274+
rolesApi: {
275+
deleteRole: vi.fn().mockResolvedValue(undefined),
276+
refreshRoleMembers: refreshRoleMembersMock,
277+
refreshRoles: refreshRolesMock,
278+
roleMembers: new Map([['topic reader/qa', [{ name: 'alice', principalType: 'User' }]]]),
279+
roles: ['topic reader/qa'],
280+
rolesError: null,
281+
},
282+
}));
283+
284+
vi.mock('../../../state/supported-features', async (importOriginal) => {
285+
const actual = await importOriginal<typeof import('../../../state/supported-features')>();
286+
287+
return {
288+
...actual,
289+
Features: {
290+
...actual.Features,
291+
createUser: true,
292+
rolesApi: true,
293+
},
294+
};
295+
});
296+
297+
vi.mock('../../../state/ui-state', () => ({
298+
uiState: {
299+
pageBreadcrumbs: [],
300+
pageTitle: '',
301+
},
302+
}));
303+
304+
vi.mock('../../license/feature-license-notification', () => ({
305+
FeatureLicenseNotification: () => null,
306+
}));
307+
308+
vi.mock('../../misc/null-fallback-boundary', () => ({
309+
NullFallbackBoundary: ({ children }: { children?: ReactNode }) => <>{children}</>,
310+
}));
311+
312+
vi.mock('../../misc/page-content', () => ({
313+
default: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
314+
}));
315+
316+
vi.mock('../../misc/section', () => ({
317+
default: ({ children }: { children?: ReactNode }) => <section>{children}</section>,
318+
}));
319+
320+
vi.mock('react-query/api/cluster-status', () => ({
321+
useGetRedpandaInfoQuery: () => ({
322+
data: {},
323+
isSuccess: true,
324+
}),
325+
}));
326+
327+
vi.mock('react-query/api/user', () => ({
328+
useInvalidateUsersCache: () => vi.fn(),
329+
useLegacyListUsersQuery: () => ({
330+
data: {
331+
users: [],
332+
},
333+
isLoading: false,
334+
}),
335+
}));
336+
337+
vi.mock('react-query/api/acl', () => ({
338+
useDeleteAclMutation: () => ({
339+
mutateAsync: vi.fn(),
340+
}),
341+
useListACLAsPrincipalGroups: () => ({
342+
data: [],
343+
error: null,
344+
isError: false,
345+
isLoading: false,
346+
}),
347+
}));
348+
349+
import AclList from './acl-list';
350+
351+
describe('AclList role navigation', () => {
352+
beforeEach(() => {
353+
vi.clearAllMocks();
354+
});
355+
356+
test('navigates role edit actions to the encoded update route', async () => {
357+
const user = userEvent.setup();
358+
359+
render(<AclList tab="roles" />);
360+
361+
await user.click(await screen.findByLabelText('Edit role topic reader/qa'));
362+
363+
expect(historyPushMock).toHaveBeenCalledWith('/security/roles/topic%20reader%2Fqa/update');
364+
});
365+
});

frontend/src/components/pages/acls/acl-list.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,8 +657,10 @@ const RolesTab = () => {
657657
return (
658658
<Flex flexDirection="row" gap={4}>
659659
<button
660+
aria-label={`Edit role ${entry.name}`}
661+
data-testid={`edit-role-button-${entry.name}`}
660662
onClick={() => {
661-
appGlobal.historyPush(`/security/roles/${entry.name}/edit`);
663+
appGlobal.historyPush(`/security/roles/${encodeURIComponent(entry.name)}/update`);
662664
}}
663665
type="button"
664666
>

frontend/src/components/pages/acls/role-details.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const RoleDetailsPageContent = ({ roleName: encodedRoleName }: { roleName: strin
101101
<Flex gap="4">
102102
<Button
103103
onClick={() => {
104-
appGlobal.historyPush(`/security/roles/${encodedRoleName}/edit`);
104+
appGlobal.historyPush(`/security/roles/${encodedRoleName}/update`);
105105
}}
106106
variant="outline"
107107
>

0 commit comments

Comments
 (0)