From 540fb93a970f605dba2e3b4967e6d32ab5363181 Mon Sep 17 00:00:00 2001 From: Ben Dronen Date: Tue, 18 Aug 2026 12:09:02 -0400 Subject: [PATCH 1/7] RFE-9146: Add service account impersonation --- frontend/e2e/pages/masthead-page.ts | 26 +++ frontend/e2e/pages/service-account-page.ts | 19 ++ .../app/service-account-impersonation.spec.ts | 53 +++++ .../console-app/console-extensions.json | 2 +- .../console-app/locales/en/console-app.json | 1 + frontend/packages/console-app/package.json | 3 +- .../providers/service-account-provider.ts | 65 +++++++ .../src/utils/console-fetch-utils.ts | 17 +- frontend/public/actions/ui.ts | 12 +- .../components/impersonate-notifier.tsx | 2 +- .../components/masthead/masthead-toolbar.tsx | 8 +- ...mpersonate-user-modal-integration.spec.tsx | 8 +- .../__tests__/impersonate-user-modal.spec.tsx | 29 ++- .../modals/impersonate-user-modal.tsx | 182 ++++++++++++++---- frontend/public/locales/en/public.json | 10 +- 15 files changed, 378 insertions(+), 59 deletions(-) create mode 100644 frontend/e2e/pages/service-account-page.ts create mode 100644 frontend/e2e/tests/console/app/service-account-impersonation.spec.ts create mode 100644 frontend/packages/console-app/src/actions/providers/service-account-provider.ts diff --git a/frontend/e2e/pages/masthead-page.ts b/frontend/e2e/pages/masthead-page.ts index 0a42493f113..488c1d665cd 100644 --- a/frontend/e2e/pages/masthead-page.ts +++ b/frontend/e2e/pages/masthead-page.ts @@ -8,6 +8,18 @@ export class MastheadPage extends BasePage { private readonly logo: Locator = this.page.getByTestId('masthead-logo'); private readonly quickCreateToggle: Locator = this.page.getByTestId('quick-create-dropdown'); private readonly userDropdownToggle: Locator = this.page.getByTestId('user-dropdown-toggle'); + private readonly impersonateUserItem: Locator = this.page.getByTestId('impersonate-user'); + private readonly stopImpersonateItem: Locator = this.page.getByTestId('stop-impersonate'); + private readonly serviceAccountRadio: Locator = this.page.getByTestId( + 'impersonate-kind-service-account', + ); + private readonly serviceAccountNamespaceInput: Locator = this.page.getByTestId( + 'service-account-namespace-input', + ); + private readonly serviceAccountNameInput: Locator = this.page.getByTestId( + 'service-account-name-input', + ); + private readonly impersonateButton: Locator = this.page.getByTestId('impersonate-button'); private readonly copyLoginCommandLink: Locator = this.page .getByTestId('copy-login-command') .locator('a'); @@ -40,6 +52,20 @@ export class MastheadPage extends BasePage { await this.userDropdownToggle.click(); } + async impersonateServiceAccount(namespace: string, name: string): Promise { + await this.openUserDropdown(); + await this.robustClick(this.impersonateUserItem); + await this.robustClick(this.serviceAccountRadio); + await this.serviceAccountNamespaceInput.fill(namespace); + await this.serviceAccountNameInput.fill(name); + await this.robustClick(this.impersonateButton); + } + + async stopImpersonating(): Promise { + await this.openUserDropdown(); + await this.robustClick(this.stopImpersonateItem); + } + async isAuthDisabled(): Promise { return this.page.evaluate(() => { const w = window as Window & { SERVER_FLAGS?: { authDisabled?: boolean } }; diff --git a/frontend/e2e/pages/service-account-page.ts b/frontend/e2e/pages/service-account-page.ts new file mode 100644 index 00000000000..088e3f12128 --- /dev/null +++ b/frontend/e2e/pages/service-account-page.ts @@ -0,0 +1,19 @@ +import type { Locator } from '@playwright/test'; + +import BasePage from './base-page'; + +export class ServiceAccountPage extends BasePage { + private readonly actionsMenuButton: Locator = this.page.getByTestId('actions-menu-button'); + private readonly impersonateAction: Locator = this.page.getByRole('menuitem', { + name: /Impersonate service account/, + }); + + async navigateToDetails(namespace: string, name: string): Promise { + await this.goTo(`/k8s/ns/${namespace}/core~v1~ServiceAccount/${name}`); + } + + async impersonateFromDetails(): Promise { + await this.robustClick(this.actionsMenuButton); + await this.robustClick(this.impersonateAction); + } +} diff --git a/frontend/e2e/tests/console/app/service-account-impersonation.spec.ts b/frontend/e2e/tests/console/app/service-account-impersonation.spec.ts new file mode 100644 index 00000000000..1efc040f100 --- /dev/null +++ b/frontend/e2e/tests/console/app/service-account-impersonation.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '../../../fixtures'; +import { warmupSPA } from '../../../pages/base-page'; +import { MastheadPage } from '../../../pages/masthead-page'; +import { ServiceAccountPage } from '../../../pages/service-account-page'; + +test.describe('ServiceAccount impersonation', { tag: ['@admin'] }, () => { + test('can impersonate a service account from the masthead and resource actions', async ({ + page, + cleanup, + k8sClient, + }) => { + const suffix = Date.now(); + const namespace = `sa-impersonation-${suffix}`; + const serviceAccountName = `impersonation-target-${suffix}`; + const serviceAccountUsername = `system:serviceaccount:${namespace}:${serviceAccountName}`; + const masthead = new MastheadPage(page); + const serviceAccountPage = new ServiceAccountPage(page); + + await test.step('Create service account', async () => { + await k8sClient.createNamespace(namespace); + await k8sClient.waitForNamespaceReady(namespace); + cleanup.trackNamespace(namespace); + await k8sClient.coreV1Api.createNamespacedServiceAccount({ + namespace, + body: { metadata: { name: serviceAccountName } }, + }); + }); + + await test.step('Impersonate service account from masthead modal', async () => { + await warmupSPA(page); + await masthead.impersonateServiceAccount(namespace, serviceAccountName); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + }); + + await test.step('Stop impersonating', async () => { + await masthead.stopImpersonating(); + await expect(page).toHaveURL(/\/$/, { timeout: 60_000 }); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate service account from resource details action', async () => { + await serviceAccountPage.navigateToDetails(namespace, serviceAccountName); + await serviceAccountPage.impersonateFromDetails(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + }); + }); +}); diff --git a/frontend/packages/console-app/console-extensions.json b/frontend/packages/console-app/console-extensions.json index 9f1a29d4869..ebb173c4aff 100644 --- a/frontend/packages/console-app/console-extensions.json +++ b/frontend/packages/console-app/console-extensions.json @@ -2686,7 +2686,7 @@ "version": "v1", "kind": "ServiceAccount" }, - "provider": { "$codeRef": "defaultProvider.useDefaultActionsProvider" } + "provider": { "$codeRef": "serviceAccountProvider.useServiceAccountActionsProvider" } } }, { diff --git a/frontend/packages/console-app/locales/en/console-app.json b/frontend/packages/console-app/locales/en/console-app.json index a65dd7dab3f..61ddd94c053 100644 --- a/frontend/packages/console-app/locales/en/console-app.json +++ b/frontend/packages/console-app/locales/en/console-app.json @@ -354,6 +354,7 @@ "ImageStreams": "ImageStreams", "Impersonate {{kind}} \"{{name}}\"": "Impersonate {{kind}} \"{{name}}\"", "Impersonate Group {{name}}": "Impersonate Group {{name}}", + "Impersonate service account {{name}}": "Impersonate service account {{name}}", "Impersonate user {{name}}": "Impersonate user {{name}}", "In progress": "In progress", "In progress ({{statusCount, number}})": "In progress ({{statusCount, number}})", diff --git a/frontend/packages/console-app/package.json b/frontend/packages/console-app/package.json index f6ce139c921..2f38b180191 100644 --- a/frontend/packages/console-app/package.json +++ b/frontend/packages/console-app/package.json @@ -99,7 +99,8 @@ "customResourceDefinitionProvider": "src/actions/providers/custom-resource-definition-provider.ts", "machineConfigPoolProvider": "src/actions/providers/machine-config-pool-provider.ts", "serviceMonitorProvider": "src/actions/providers/service-monitor-provider.ts", - "userProvider": "src/actions/providers/user-provider.ts" + "userProvider": "src/actions/providers/user-provider.ts", + "serviceAccountProvider": "src/actions/providers/service-account-provider.ts" } } } diff --git a/frontend/packages/console-app/src/actions/providers/service-account-provider.ts b/frontend/packages/console-app/src/actions/providers/service-account-provider.ts new file mode 100644 index 00000000000..f4c60b0a333 --- /dev/null +++ b/frontend/packages/console-app/src/actions/providers/service-account-provider.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import type { ExtensionHook } from '@console/dynamic-plugin-sdk/src/api/common-types'; +import type { Action } from '@console/dynamic-plugin-sdk/src/extensions/actions'; +import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src/extensions/console-types'; +import * as UIActions from '@console/internal/actions/ui'; +import { asAccessReview } from '@console/internal/components/utils'; +import { ServiceAccountModel } from '@console/internal/models'; +import { referenceFor } from '@console/internal/module/k8s'; +import { useConsoleDispatch } from '@console/shared/src/hooks/useConsoleDispatch'; +import { useK8sModel } from '@console/shared/src/hooks/useK8sModel'; +import { useCommonResourceActions } from '../hooks/useCommonResourceActions'; + +const serviceAccountUsername = (namespace: string | undefined, name: string | undefined) => + `system:serviceaccount:${namespace}:${name}`; + +const useImpersonateAction = (resource: K8sResourceKind): Action[] => { + const { t } = useTranslation('console-app'); + const navigate = useNavigate(); + const dispatch = useConsoleDispatch(); + + const factory = useMemo( + () => ({ + ImpersonateServiceAccount: () => ({ + id: 'impersonate-service-account', + label: t('Impersonate service account {{name}}', { name: resource?.metadata?.name }), + cta: () => { + dispatch( + UIActions.startImpersonate( + 'ServiceAccount', + serviceAccountUsername(resource?.metadata?.namespace, resource?.metadata?.name), + ), + ); + navigate(window.SERVER_FLAGS.basePath); + }, + accessReview: asAccessReview(ServiceAccountModel, resource, 'impersonate'), + }), + }), + [dispatch, navigate, resource, t], + ); + + return useMemo( + () => + resource?.metadata?.namespace && resource?.metadata?.name + ? [factory.ImpersonateServiceAccount()] + : [], + [factory, resource?.metadata?.name, resource?.metadata?.namespace], + ); +}; + +export const useServiceAccountActionsProvider: ExtensionHook = ( + resource, +) => { + const [kindObj, inFlight] = useK8sModel(referenceFor(resource)); + const impersonateAction = useImpersonateAction(resource); + const commonActions = useCommonResourceActions(kindObj, resource); + + const actions = useMemo( + () => [...impersonateAction, ...commonActions], + [commonActions, impersonateAction], + ); + + return [actions, !inFlight, false]; +}; diff --git a/frontend/packages/console-shared/src/utils/console-fetch-utils.ts b/frontend/packages/console-shared/src/utils/console-fetch-utils.ts index 9212fe85361..59a43b1eb62 100644 --- a/frontend/packages/console-shared/src/utils/console-fetch-utils.ts +++ b/frontend/packages/console-shared/src/utils/console-fetch-utils.ts @@ -34,16 +34,25 @@ export const getConsoleRequestHeaders: GetConsoleRequestHeaders = () => { if (impersonateData) { const { kind, name, groups } = impersonateData; - if (kind === 'User' && name) { - // Simple user impersonation + if ( + (kind === 'User' || kind === 'ServiceAccount') && + name && + (!groups || groups.length === 0) + ) { + // Simple user or service account impersonation headers['Impersonate-User'] = name; } else if (kind === 'Group' && name) { // Single group impersonation (backward compatibility) // Even if we are impersonating a group, we still need to set Impersonate-User to something or k8s will complain headers['Impersonate-User'] = name; headers['Impersonate-Group'] = name; - } else if (kind === 'UserWithGroups' && name && groups && groups.length > 0) { - // User with multiple groups impersonation + } else if ( + (kind === 'UserWithGroups' || kind === 'ServiceAccount') && + name && + groups && + groups.length > 0 + ) { + // User or service account with multiple groups impersonation headers['Impersonate-User'] = name; // Note: This creates an array of values for the same header key headers['Impersonate-Group'] = groups; diff --git a/frontend/public/actions/ui.ts b/frontend/public/actions/ui.ts index 393c0ee83e2..6a1dc96c142 100644 --- a/frontend/public/actions/ui.ts +++ b/frontend/public/actions/ui.ts @@ -197,15 +197,17 @@ export const startImpersonate = const encodedName = encodeImpersonationValue(name, textEncoder); let subprotocols; - if (kind === 'User') { + if ((kind === 'User' || kind === 'ServiceAccount') && (!groups || groups.length === 0)) { subprotocols = [`Impersonate-User.${encodedName}`]; } else if (kind === 'Group') { subprotocols = [`Impersonate-Group.${encodedName}`]; - } else if (kind === 'UserWithGroups' && groups && groups.length > 0) { - // User with multiple groups impersonation - // Encode user subprotocol + } else if ( + (kind === 'UserWithGroups' || kind === 'ServiceAccount') && + groups && + groups.length > 0 + ) { + // User or service account with multiple groups impersonation subprotocols = [`Impersonate-User.${encodedName}`]; - // Encode each group as a separate subprotocol groups.forEach((group) => { const encodedGroup = encodeImpersonationValue(group, textEncoder); subprotocols.push(`Impersonate-Group.${encodedGroup}`); diff --git a/frontend/public/components/impersonate-notifier.tsx b/frontend/public/components/impersonate-notifier.tsx index 0ab6e919ee3..0fdf959fc06 100644 --- a/frontend/public/components/impersonate-notifier.tsx +++ b/frontend/public/components/impersonate-notifier.tsx @@ -46,7 +46,7 @@ export const ImpersonateNotifier = connect( // Enhanced group display with tooltip for many groups const MAX_GROUPS_DISPLAY = 2; const groups = impersonate.groups || []; - const hasGroups = isUserWithGroups && groups.length > 0; + const hasGroups = groups.length > 0; const visibleGroups = groups.slice(0, MAX_GROUPS_DISPLAY); const remainingCount = Math.max(0, groups.length - MAX_GROUPS_DISPLAY); diff --git a/frontend/public/components/masthead/masthead-toolbar.tsx b/frontend/public/components/masthead/masthead-toolbar.tsx index 14ad935b147..8d10e8427f4 100644 --- a/frontend/public/components/masthead/masthead-toolbar.tsx +++ b/frontend/public/components/masthead/masthead-toolbar.tsx @@ -863,11 +863,13 @@ const MastheadToolbarContents: FC = ({ setIsImpersonateModalOpen(false)} - onImpersonate={(userName: string, groups: string[]) => { - if (groups && groups.length > 0) { + onImpersonate={(userName: string, groups: string[], kind: 'User' | 'ServiceAccount') => { + if (kind === 'ServiceAccount') { + dispatch(UIActions.startImpersonate(kind, userName, groups)); + } else if (groups && groups.length > 0) { dispatch(UIActions.startImpersonate('UserWithGroups', userName, groups)); } else { - dispatch(UIActions.startImpersonate('User', userName)); + dispatch(UIActions.startImpersonate(kind, userName)); } setIsImpersonateModalOpen(false); // Redirect to projects page to prevent RBAC issues for impersonated users diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx index c935378c0c2..20641d7d357 100644 --- a/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx +++ b/frontend/public/components/modals/__tests__/impersonate-user-modal-integration.spec.tsx @@ -75,7 +75,7 @@ describe('ImpersonateUserModal Integration Tests', () => { await user.click(submitButton); await waitFor(() => { - expect(onImpersonate).toHaveBeenCalledWith('testuser', []); + expect(onImpersonate).toHaveBeenCalledWith('testuser', [], 'User'); expect(mockStartImpersonate).toHaveBeenCalledWith('User', 'testuser'); }); }); @@ -113,7 +113,7 @@ describe('ImpersonateUserModal Integration Tests', () => { await user.click(submitButton); await waitFor(() => { - expect(onImpersonate).toHaveBeenCalledWith('multiuser', ['developers']); + expect(onImpersonate).toHaveBeenCalledWith('multiuser', ['developers'], 'User'); expect(mockStartImpersonate).toHaveBeenCalledWith('UserWithGroups', 'multiuser', [ 'developers', ]); @@ -169,7 +169,7 @@ describe('ImpersonateUserModal Integration Tests', () => { await user.click(submitButton); await waitFor(() => { - expect(onImpersonate).toHaveBeenCalledWith('groupuser', ['developers', 'admins']); + expect(onImpersonate).toHaveBeenCalledWith('groupuser', ['developers', 'admins'], 'User'); }); }); @@ -218,7 +218,7 @@ describe('ImpersonateUserModal Integration Tests', () => { await user.click(submitButton); await waitFor(() => { - expect(onImpersonate).toHaveBeenCalledWith('deselectuser', []); + expect(onImpersonate).toHaveBeenCalledWith('deselectuser', [], 'User'); }); }); }); diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx index 1f2de4520e8..9838c0fc0ea 100644 --- a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx +++ b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx @@ -192,7 +192,7 @@ describe('ImpersonateUserModal', () => { await user.click(submitButton); await waitFor(() => { - expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', []); + expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', [], 'User'); }); }); @@ -210,7 +210,32 @@ describe('ImpersonateUserModal', () => { await user.click(submitButton); await waitFor(() => { - expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', []); + expect(mockOnImpersonate).toHaveBeenCalledWith('testuser', [], 'User'); + }); + }); + + it('should call onImpersonate with service account username and groups', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId('impersonate-kind-service-account')); + await user.type(screen.getByTestId('service-account-namespace-input'), 'test-ns'); + await user.type(screen.getByTestId('service-account-name-input'), 'builder'); + + const groupInput = screen.getByPlaceholderText('Enter groups'); + await user.click(groupInput); + await user.click(await screen.findByText('developers')); + + await user.click(screen.getByTestId('impersonate-button')); + + await waitFor(() => { + expect(mockOnImpersonate).toHaveBeenCalledWith( + 'system:serviceaccount:test-ns:builder', + ['developers'], + 'ServiceAccount', + ); }); }); diff --git a/frontend/public/components/modals/impersonate-user-modal.tsx b/frontend/public/components/modals/impersonate-user-modal.tsx index 902e7210d9a..a45b9534e58 100644 --- a/frontend/public/components/modals/impersonate-user-modal.tsx +++ b/frontend/public/components/modals/impersonate-user-modal.tsx @@ -26,6 +26,7 @@ import { HelperTextItem, Flex, FlexItem, + Radio, } from '@patternfly/react-core'; import { RhUiCloseIcon, RhUiErrorFillIcon } from '@patternfly/react-icons'; import { useTranslation } from 'react-i18next'; @@ -37,10 +38,12 @@ import { useK8sWatchResource } from '../utils/k8s-watch-hook'; const SELECT_ALL_KEY = '__select_all__'; const MAX_VISIBLE_CHIPS = 5; +type ImpersonateSubjectKind = 'User' | 'ServiceAccount'; + export interface ImpersonateUserModalProps { isOpen: boolean; onClose: () => void; - onImpersonate: (username: string, groups: string[]) => void; + onImpersonate: (username: string, groups: string[], kind: ImpersonateSubjectKind) => void; prefilledUsername?: string; isUsernameReadonly?: boolean; } @@ -53,7 +56,10 @@ export const ImpersonateUserModal: FC = ({ isUsernameReadonly = false, }) => { const { t } = useTranslation('public'); + const [impersonateKind, setImpersonateKind] = useState('User'); const [username, setUsername] = useState(prefilledUsername); + const [serviceAccountNamespace, setServiceAccountNamespace] = useState(''); + const [serviceAccountName, setServiceAccountName] = useState(''); const [selectedGroups, setSelectedGroups] = useState([]); const [usernameError, setUsernameError] = useState(''); const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false); @@ -79,7 +85,10 @@ export const ImpersonateUserModal: FC = ({ }, [groups, groupsLoaded, groupsLoadError]); const handleClose = useCallback(() => { + setImpersonateKind('User'); setUsername(prefilledUsername); + setServiceAccountNamespace(''); + setServiceAccountName(''); setSelectedGroups([]); setUsernameError(''); onClose(); @@ -140,16 +149,30 @@ export const ImpersonateUserModal: FC = ({ }; const validateForm = (): boolean => { - if (!username.trim()) { + if (impersonateKind === 'User' && !username.trim()) { setUsernameError(t('Username is required')); return false; } + + if (impersonateKind === 'ServiceAccount' && !serviceAccountNamespace.trim()) { + setUsernameError(t('Service account namespace is required')); + return false; + } + + if (impersonateKind === 'ServiceAccount' && !serviceAccountName.trim()) { + setUsernameError(t('Service account name is required')); + return false; + } return true; }; const handleImpersonate = () => { if (validateForm()) { - onImpersonate(username.trim(), selectedGroups); + const impersonateUsername = + impersonateKind === 'ServiceAccount' + ? `system:serviceaccount:${serviceAccountNamespace.trim()}:${serviceAccountName.trim()}` + : username.trim(); + onImpersonate(impersonateUsername, selectedGroups, impersonateKind); handleClose(); } }; @@ -157,7 +180,10 @@ export const ImpersonateUserModal: FC = ({ // Reset form when modal opens with new prefilled username useEffect(() => { if (isOpen) { + setImpersonateKind('User'); setUsername(prefilledUsername); + setServiceAccountNamespace(''); + setServiceAccountName(''); setSelectedGroups([]); setUsernameError(''); setGroupSearchFilter(''); @@ -185,6 +211,11 @@ export const ImpersonateUserModal: FC = ({ const textInputGroupRef = useRef(null); + const isImpersonateDisabled = + impersonateKind === 'ServiceAccount' + ? !serviceAccountNamespace.trim() || !serviceAccountName.trim() + : !username.trim(); + const toggle = (toggleRef: Ref) => ( = ({ variant={AlertVariant.warning} isInline title={t( - 'Impersonating a user grants you their exact permissions. You must enter username, but you can also enter a group to simulate the permissions of a member of that group.', + 'Impersonating a user or service account grants you their exact permissions. You must enter a username or service account, but you can also enter a group to simulate the permissions of a member of that group.', )} /> + + { + setImpersonateKind('User'); + setUsernameError(''); + }} + data-test="impersonate-kind-user" + /> + { + setImpersonateKind('ServiceAccount'); + setUsernameError(''); + }} + data-test="impersonate-kind-service-account" + /> + + {groupsLoadError && ( {groupsLoadError.message} )} - - {t('Username')} - {t('The name of the user to impersonate')} - - } - fieldId="impersonate-username" - isRequired - > - handleUsernameChange(value)} - readOnly={isUsernameReadonly} - placeholder={t('Enter a username')} - data-test="username-input" - validated={usernameError ? 'error' : 'default'} - aria-label={t('Username to impersonate')} - aria-describedby="username-help-text" - /> - {usernameError && ( - - - }> - {usernameError} - - - - )} - + {impersonateKind === 'User' ? ( + + {t('Username')} + {t('The name of the user to impersonate')} + + } + fieldId="impersonate-username" + isRequired + > + handleUsernameChange(value)} + readOnly={isUsernameReadonly} + placeholder={t('Enter a username')} + data-test="username-input" + validated={usernameError ? 'error' : 'default'} + aria-label={t('Username to impersonate')} + aria-describedby="username-help-text" + /> + {usernameError && ( + + + }> + {usernameError} + + + + )} + + ) : ( + <> + + { + setServiceAccountNamespace(value); + setUsernameError(''); + }} + placeholder={t('Enter a namespace')} + data-test="service-account-namespace-input" + validated={usernameError ? 'error' : 'default'} + aria-label={t('Service account namespace to impersonate')} + /> + + + { + setServiceAccountName(value); + setUsernameError(''); + }} + placeholder={t('Enter a service account name')} + data-test="service-account-name-input" + validated={usernameError ? 'error' : 'default'} + aria-label={t('Service account name to impersonate')} + /> + {usernameError && ( + + + }> + {usernameError} + + + + )} + + + )} = ({ key="impersonate" variant="primary" onClick={handleImpersonate} - isDisabled={!username.trim()} + isDisabled={isImpersonateDisabled} data-test="impersonate-button" > {t('Impersonate')} diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 7078a415270..0e471713a26 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -620,6 +620,8 @@ "Emoji": "Emoji", "emoji code": "emoji code", "Enter a font size": "Enter a font size", + "Enter a namespace": "Enter a namespace", + "Enter a service account name": "Enter a service account name", "Enter a username": "Enter a username", "Enter groups": "Enter groups", "Enter name": "Enter name", @@ -797,7 +799,7 @@ "Immutable, if set to true, ensures that data stored in the ConfigMap cannot be updated": "Immutable, if set to true, ensures that data stored in the ConfigMap cannot be updated", "Impersonate": "Impersonate", "Impersonate user": "Impersonate user", - "Impersonating a user grants you their exact permissions. You must enter username, but you can also enter a group to simulate the permissions of a member of that group.": "Impersonating a user grants you their exact permissions. You must enter username, but you can also enter a group to simulate the permissions of a member of that group.", + "Impersonating a user or service account grants you their exact permissions. You must enter a username or service account, but you can also enter a group to simulate the permissions of a member of that group.": "Impersonating a user or service account grants you their exact permissions. You must enter a username or service account, but you can also enter a group to simulate the permissions of a member of that group.", "Import code from your Git repository to be built and deployed": "Import code from your Git repository to be built and deployed", "Import from Git": "Import from Git", "Import more YAML": "Import more YAML", @@ -1394,6 +1396,12 @@ "Send resolved alerts to this receiver?": "Send resolved alerts to this receiver?", "Served": "Served", "Service": "Service", + "Service account name": "Service account name", + "Service account name is required": "Service account name is required", + "Service account name to impersonate": "Service account name to impersonate", + "Service account namespace": "Service account namespace", + "Service account namespace is required": "Service account namespace is required", + "Service account namespace to impersonate": "Service account namespace to impersonate", "Service Account Token": "Service Account Token", "Service key": "Service key", "Service Level Agreement (SLA)": "Service Level Agreement (SLA)", From 4f8a9ff60e366d8e0048380c8eddf1f887413470 Mon Sep 17 00:00:00 2001 From: Ben Dronen Date: Tue, 18 Aug 2026 12:38:54 -0400 Subject: [PATCH 2/7] RFE-9146: Expand impersonation e2e coverage --- frontend/e2e/pages/masthead-page.ts | 26 ++++- frontend/e2e/pages/service-account-page.ts | 7 +- .../tests/console/app/impersonation.spec.ts | 109 ++++++++++++++++++ .../app/service-account-impersonation.spec.ts | 53 --------- 4 files changed, 139 insertions(+), 56 deletions(-) create mode 100644 frontend/e2e/tests/console/app/impersonation.spec.ts delete mode 100644 frontend/e2e/tests/console/app/service-account-impersonation.spec.ts diff --git a/frontend/e2e/pages/masthead-page.ts b/frontend/e2e/pages/masthead-page.ts index 488c1d665cd..84e584b212f 100644 --- a/frontend/e2e/pages/masthead-page.ts +++ b/frontend/e2e/pages/masthead-page.ts @@ -10,6 +10,7 @@ export class MastheadPage extends BasePage { private readonly userDropdownToggle: Locator = this.page.getByTestId('user-dropdown-toggle'); private readonly impersonateUserItem: Locator = this.page.getByTestId('impersonate-user'); private readonly stopImpersonateItem: Locator = this.page.getByTestId('stop-impersonate'); + private readonly usernameInput: Locator = this.page.getByTestId('username-input'); private readonly serviceAccountRadio: Locator = this.page.getByTestId( 'impersonate-kind-service-account', ); @@ -19,6 +20,7 @@ export class MastheadPage extends BasePage { private readonly serviceAccountNameInput: Locator = this.page.getByTestId( 'service-account-name-input', ); + private readonly groupInput: Locator = this.page.getByPlaceholder('Enter groups'); private readonly impersonateButton: Locator = this.page.getByTestId('impersonate-button'); private readonly copyLoginCommandLink: Locator = this.page .getByTestId('copy-login-command') @@ -52,12 +54,34 @@ export class MastheadPage extends BasePage { await this.userDropdownToggle.click(); } - async impersonateServiceAccount(namespace: string, name: string): Promise { + private async selectGroups(groups: string[]): Promise { + for (const group of groups) { + await this.groupInput.click(); + const groupOption = this.page.getByText(group, { exact: true }); + await this.robustClick(groupOption); + } + await this.page.mouse.click(20, 20); + } + + async impersonateUser(username: string, groups: string[] = []): Promise { + await this.openUserDropdown(); + await this.robustClick(this.impersonateUserItem); + await this.usernameInput.fill(username); + await this.selectGroups(groups); + await this.robustClick(this.impersonateButton); + } + + async impersonateServiceAccount( + namespace: string, + name: string, + groups: string[] = [], + ): Promise { await this.openUserDropdown(); await this.robustClick(this.impersonateUserItem); await this.robustClick(this.serviceAccountRadio); await this.serviceAccountNamespaceInput.fill(namespace); await this.serviceAccountNameInput.fill(name); + await this.selectGroups(groups); await this.robustClick(this.impersonateButton); } diff --git a/frontend/e2e/pages/service-account-page.ts b/frontend/e2e/pages/service-account-page.ts index 088e3f12128..2584e483d9b 100644 --- a/frontend/e2e/pages/service-account-page.ts +++ b/frontend/e2e/pages/service-account-page.ts @@ -1,4 +1,4 @@ -import type { Locator } from '@playwright/test'; +import { expect, type Locator } from '@playwright/test'; import BasePage from './base-page'; @@ -9,7 +9,10 @@ export class ServiceAccountPage extends BasePage { }); async navigateToDetails(namespace: string, name: string): Promise { - await this.goTo(`/k8s/ns/${namespace}/core~v1~ServiceAccount/${name}`); + await this.goTo(`/k8s/ns/${namespace}/~v1~ServiceAccount/${name}`); + await expect(this.page.getByRole('heading', { name: new RegExp(`ServiceAccount.*${name}`) })).toBeVisible({ + timeout: 60_000, + }); } async impersonateFromDetails(): Promise { diff --git a/frontend/e2e/tests/console/app/impersonation.spec.ts b/frontend/e2e/tests/console/app/impersonation.spec.ts new file mode 100644 index 00000000000..6a364775adc --- /dev/null +++ b/frontend/e2e/tests/console/app/impersonation.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from '../../../fixtures'; +import { warmupSPA } from '../../../pages/base-page'; +import { MastheadPage } from '../../../pages/masthead-page'; +import { ServiceAccountPage } from '../../../pages/service-account-page'; + +test.describe('Impersonation', { tag: ['@admin'] }, () => { + test('can impersonate users and service accounts with groups', async ({ + page, + cleanup, + k8sClient, + }) => { + const suffix = Date.now(); + const namespace = `sa-impersonation-${suffix}`; + const serviceAccountName = `impersonation-target-${suffix}`; + const groupName = `impersonation-group-${suffix}`; + const username = `impersonation-user-${suffix}`; + const serviceAccountUsername = `system:serviceaccount:${namespace}:${serviceAccountName}`; + const masthead = new MastheadPage(page); + const serviceAccountPage = new ServiceAccountPage(page); + + await test.step('Create service account and group', async () => { + await k8sClient.createNamespace(namespace); + await k8sClient.waitForNamespaceReady(namespace); + cleanup.trackNamespace(namespace); + await k8sClient.coreV1Api.createNamespacedServiceAccount({ + namespace, + body: { metadata: { name: serviceAccountName } }, + }); + await k8sClient.customObjectsApi.createClusterCustomObject({ + group: 'user.openshift.io', + version: 'v1', + plural: 'groups', + body: { + apiVersion: 'user.openshift.io/v1', + kind: 'Group', + metadata: { name: groupName }, + }, + }); + cleanup.trackClusterCustomResource(groupName, 'user.openshift.io', 'v1', 'groups', 'Group'); + }); + + await test.step('Impersonate user from masthead modal', async () => { + await warmupSPA(page); + await masthead.impersonateUser(username); + await expect(page.getByText(`You are impersonating User ${username}`)).toBeVisible({ + timeout: 60_000, + }); + }); + + await test.step('Stop impersonating user', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating User ${username}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate user with group from masthead modal', async () => { + await masthead.impersonateUser(username, [groupName]); + await expect(page.getByText(`You are impersonating user ${username}`)).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByText(`with groups: ${groupName}`)).toBeVisible({ timeout: 60_000 }); + }); + + await test.step('Stop impersonating user with group', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating user ${username}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate service account from masthead modal', async () => { + await masthead.impersonateServiceAccount(namespace, serviceAccountName); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + }); + + await test.step('Stop impersonating service account', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate service account with group from masthead modal', async () => { + await masthead.impersonateServiceAccount(namespace, serviceAccountName, [groupName]); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByText(`with groups: ${groupName}`)).toBeVisible({ timeout: 60_000 }); + }); + + await test.step('Stop impersonating service account with group', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate service account from resource details action', async () => { + await serviceAccountPage.navigateToDetails(namespace, serviceAccountName); + await serviceAccountPage.impersonateFromDetails(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + }); + }); +}); diff --git a/frontend/e2e/tests/console/app/service-account-impersonation.spec.ts b/frontend/e2e/tests/console/app/service-account-impersonation.spec.ts deleted file mode 100644 index 1efc040f100..00000000000 --- a/frontend/e2e/tests/console/app/service-account-impersonation.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { test, expect } from '../../../fixtures'; -import { warmupSPA } from '../../../pages/base-page'; -import { MastheadPage } from '../../../pages/masthead-page'; -import { ServiceAccountPage } from '../../../pages/service-account-page'; - -test.describe('ServiceAccount impersonation', { tag: ['@admin'] }, () => { - test('can impersonate a service account from the masthead and resource actions', async ({ - page, - cleanup, - k8sClient, - }) => { - const suffix = Date.now(); - const namespace = `sa-impersonation-${suffix}`; - const serviceAccountName = `impersonation-target-${suffix}`; - const serviceAccountUsername = `system:serviceaccount:${namespace}:${serviceAccountName}`; - const masthead = new MastheadPage(page); - const serviceAccountPage = new ServiceAccountPage(page); - - await test.step('Create service account', async () => { - await k8sClient.createNamespace(namespace); - await k8sClient.waitForNamespaceReady(namespace); - cleanup.trackNamespace(namespace); - await k8sClient.coreV1Api.createNamespacedServiceAccount({ - namespace, - body: { metadata: { name: serviceAccountName } }, - }); - }); - - await test.step('Impersonate service account from masthead modal', async () => { - await warmupSPA(page); - await masthead.impersonateServiceAccount(namespace, serviceAccountName); - await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ - timeout: 60_000, - }); - }); - - await test.step('Stop impersonating', async () => { - await masthead.stopImpersonating(); - await expect(page).toHaveURL(/\/$/, { timeout: 60_000 }); - await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ - timeout: 60_000, - }); - }); - - await test.step('Impersonate service account from resource details action', async () => { - await serviceAccountPage.navigateToDetails(namespace, serviceAccountName); - await serviceAccountPage.impersonateFromDetails(); - await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ - timeout: 60_000, - }); - }); - }); -}); From 81802896e72d358576b791df649547a2e939c89a Mon Sep 17 00:00:00 2001 From: Ben Dronen Date: Tue, 18 Aug 2026 12:56:13 -0400 Subject: [PATCH 3/7] RFE-9146: Validate service account impersonation input --- .../__tests__/impersonate-user-modal.spec.tsx | 24 ++++++ .../modals/impersonate-user-modal.tsx | 85 +++++++++++++++---- frontend/public/locales/en/public.json | 2 + 3 files changed, 96 insertions(+), 15 deletions(-) diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx index 9838c0fc0ea..5505bd172bd 100644 --- a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx +++ b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx @@ -239,6 +239,30 @@ describe('ImpersonateUserModal', () => { }); }); + it('should reject invalid service account namespace and name values', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId('impersonate-kind-service-account')); + await user.type(screen.getByTestId('service-account-namespace-input'), 'Invalid_Namespace'); + await user.type(screen.getByTestId('service-account-name-input'), 'Builder'); + await user.click(screen.getByTestId('impersonate-button')); + + expect( + screen.getByText( + 'Service account namespace must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number.', + ), + ).toBeVisible(); + expect( + screen.getByText( + 'Service account name must contain only lowercase letters, numbers, hyphens, and dots, and must start and end with a letter or number.', + ), + ).toBeVisible(); + expect(mockOnImpersonate).not.toHaveBeenCalled(); + }); + it('should close modal after successful submission', async () => { const user = userEvent.setup(); render( diff --git a/frontend/public/components/modals/impersonate-user-modal.tsx b/frontend/public/components/modals/impersonate-user-modal.tsx index a45b9534e58..c4f0d71be6c 100644 --- a/frontend/public/components/modals/impersonate-user-modal.tsx +++ b/frontend/public/components/modals/impersonate-user-modal.tsx @@ -37,6 +37,15 @@ import { useK8sWatchResource } from '../utils/k8s-watch-hook'; const SELECT_ALL_KEY = '__select_all__'; const MAX_VISIBLE_CHIPS = 5; +const DNS_LABEL_MAX_LENGTH = 63; +const DNS_SUBDOMAIN_MAX_LENGTH = 253; +const DNS_LABEL_REGEXP = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/; + +const isDNS1123Label = (value: string): boolean => + value.length <= DNS_LABEL_MAX_LENGTH && DNS_LABEL_REGEXP.test(value); + +const isDNS1123Subdomain = (value: string): boolean => + value.length <= DNS_SUBDOMAIN_MAX_LENGTH && value.split('.').every(isDNS1123Label); type ImpersonateSubjectKind = 'User' | 'ServiceAccount'; @@ -62,6 +71,8 @@ export const ImpersonateUserModal: FC = ({ const [serviceAccountName, setServiceAccountName] = useState(''); const [selectedGroups, setSelectedGroups] = useState([]); const [usernameError, setUsernameError] = useState(''); + const [serviceAccountNamespaceError, setServiceAccountNamespaceError] = useState(''); + const [serviceAccountNameError, setServiceAccountNameError] = useState(''); const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false); const [showAllGroups, setShowAllGroups] = useState(false); const [groupSearchFilter, setGroupSearchFilter] = useState(''); @@ -91,6 +102,8 @@ export const ImpersonateUserModal: FC = ({ setServiceAccountName(''); setSelectedGroups([]); setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); onClose(); }, [prefilledUsername, onClose]); @@ -149,20 +162,47 @@ export const ImpersonateUserModal: FC = ({ }; const validateForm = (): boolean => { + setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); + if (impersonateKind === 'User' && !username.trim()) { setUsernameError(t('Username is required')); return false; } - if (impersonateKind === 'ServiceAccount' && !serviceAccountNamespace.trim()) { - setUsernameError(t('Service account namespace is required')); - return false; - } + if (impersonateKind === 'ServiceAccount') { + const trimmedNamespace = serviceAccountNamespace.trim(); + const trimmedName = serviceAccountName.trim(); + let isValid = true; - if (impersonateKind === 'ServiceAccount' && !serviceAccountName.trim()) { - setUsernameError(t('Service account name is required')); - return false; + if (!trimmedNamespace) { + setServiceAccountNamespaceError(t('Service account namespace is required')); + isValid = false; + } else if (!isDNS1123Label(trimmedNamespace)) { + setServiceAccountNamespaceError( + t( + 'Service account namespace must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number.', + ), + ); + isValid = false; + } + + if (!trimmedName) { + setServiceAccountNameError(t('Service account name is required')); + isValid = false; + } else if (!isDNS1123Subdomain(trimmedName)) { + setServiceAccountNameError( + t( + 'Service account name must contain only lowercase letters, numbers, hyphens, and dots, and must start and end with a letter or number.', + ), + ); + isValid = false; + } + + return isValid; } + return true; }; @@ -186,6 +226,8 @@ export const ImpersonateUserModal: FC = ({ setServiceAccountName(''); setSelectedGroups([]); setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); setGroupSearchFilter(''); setShowAllGroups(false); } @@ -277,10 +319,12 @@ export const ImpersonateUserModal: FC = ({ id="impersonate-kind-user" name="impersonate-kind" label={t('User')} - checked={impersonateKind === 'User'} + isChecked={impersonateKind === 'User'} onChange={() => { setImpersonateKind('User'); setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); }} data-test="impersonate-kind-user" /> @@ -288,10 +332,12 @@ export const ImpersonateUserModal: FC = ({ id="impersonate-kind-service-account" name="impersonate-kind" label={t('ServiceAccount')} - checked={impersonateKind === 'ServiceAccount'} + isChecked={impersonateKind === 'ServiceAccount'} onChange={() => { setImpersonateKind('ServiceAccount'); setUsernameError(''); + setServiceAccountNamespaceError(''); + setServiceAccountNameError(''); }} data-test="impersonate-kind-service-account" /> @@ -349,13 +395,22 @@ export const ImpersonateUserModal: FC = ({ value={serviceAccountNamespace} onChange={(_event, value) => { setServiceAccountNamespace(value); - setUsernameError(''); + setServiceAccountNamespaceError(''); }} placeholder={t('Enter a namespace')} data-test="service-account-namespace-input" - validated={usernameError ? 'error' : 'default'} + validated={serviceAccountNamespaceError ? 'error' : 'default'} aria-label={t('Service account namespace to impersonate')} /> + {serviceAccountNamespaceError && ( + + + }> + {serviceAccountNamespaceError} + + + + )} = ({ value={serviceAccountName} onChange={(_event, value) => { setServiceAccountName(value); - setUsernameError(''); + setServiceAccountNameError(''); }} placeholder={t('Enter a service account name')} data-test="service-account-name-input" - validated={usernameError ? 'error' : 'default'} + validated={serviceAccountNameError ? 'error' : 'default'} aria-label={t('Service account name to impersonate')} /> - {usernameError && ( + {serviceAccountNameError && ( }> - {usernameError} + {serviceAccountNameError} diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 0e471713a26..7f25eb811f8 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -1398,9 +1398,11 @@ "Service": "Service", "Service account name": "Service account name", "Service account name is required": "Service account name is required", + "Service account name must contain only lowercase letters, numbers, hyphens, and dots, and must start and end with a letter or number.": "Service account name must contain only lowercase letters, numbers, hyphens, and dots, and must start and end with a letter or number.", "Service account name to impersonate": "Service account name to impersonate", "Service account namespace": "Service account namespace", "Service account namespace is required": "Service account namespace is required", + "Service account namespace must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number.": "Service account namespace must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number.", "Service account namespace to impersonate": "Service account namespace to impersonate", "Service Account Token": "Service Account Token", "Service key": "Service key", From 8ef978a6dd1c512a55839212b8951c9329e5eed9 Mon Sep 17 00:00:00 2001 From: Ben Dronen Date: Tue, 18 Aug 2026 13:20:55 -0400 Subject: [PATCH 4/7] RFE-9146: Address impersonation review feedback --- frontend/e2e/pages/service-account-page.ts | 6 +++++- frontend/public/actions/ui.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/e2e/pages/service-account-page.ts b/frontend/e2e/pages/service-account-page.ts index 2584e483d9b..f6fbbe7f171 100644 --- a/frontend/e2e/pages/service-account-page.ts +++ b/frontend/e2e/pages/service-account-page.ts @@ -2,6 +2,8 @@ import { expect, type Locator } from '@playwright/test'; import BasePage from './base-page'; +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + export class ServiceAccountPage extends BasePage { private readonly actionsMenuButton: Locator = this.page.getByTestId('actions-menu-button'); private readonly impersonateAction: Locator = this.page.getByRole('menuitem', { @@ -10,7 +12,9 @@ export class ServiceAccountPage extends BasePage { async navigateToDetails(namespace: string, name: string): Promise { await this.goTo(`/k8s/ns/${namespace}/~v1~ServiceAccount/${name}`); - await expect(this.page.getByRole('heading', { name: new RegExp(`ServiceAccount.*${name}`) })).toBeVisible({ + await expect( + this.page.getByRole('heading', { name: new RegExp(`ServiceAccount.*${escapeRegExp(name)}`) }), + ).toBeVisible({ timeout: 60_000, }); } diff --git a/frontend/public/actions/ui.ts b/frontend/public/actions/ui.ts index 6a1dc96c142..641f39c1b5a 100644 --- a/frontend/public/actions/ui.ts +++ b/frontend/public/actions/ui.ts @@ -190,7 +190,7 @@ export const startImpersonate = const imp = getImpersonate(getState()); if ((imp?.name && imp.name !== name) || (imp?.kind && imp.kind !== kind)) { // eslint-disable-next-line no-console - console.warn(`Impersonate race detected: ${name} vs ${imp.name} / ${kind} ${imp.kind}`); + console.warn('Impersonate race detected. Ignoring stale impersonation request.'); return; } From 8ab8c480cab8803e21c26b179418c6ca1b2aae55 Mon Sep 17 00:00:00 2001 From: Ben Dronen Date: Tue, 18 Aug 2026 13:56:17 -0400 Subject: [PATCH 5/7] RFE-9146: Stabilize impersonation e2e navigation --- frontend/e2e/pages/masthead-page.ts | 10 +++++++++- frontend/e2e/pages/service-account-page.ts | 6 +----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/e2e/pages/masthead-page.ts b/frontend/e2e/pages/masthead-page.ts index 84e584b212f..873284ad9e7 100644 --- a/frontend/e2e/pages/masthead-page.ts +++ b/frontend/e2e/pages/masthead-page.ts @@ -86,8 +86,16 @@ export class MastheadPage extends BasePage { } async stopImpersonating(): Promise { + const currentURL = this.page.url(); await this.openUserDropdown(); - await this.robustClick(this.stopImpersonateItem); + await Promise.all([ + this.page.waitForURL((url) => url.href !== currentURL, { + timeout: 60_000, + waitUntil: 'domcontentloaded', + }), + this.robustClick(this.stopImpersonateItem), + ]); + await this.page.waitForLoadState('domcontentloaded'); } async isAuthDisabled(): Promise { diff --git a/frontend/e2e/pages/service-account-page.ts b/frontend/e2e/pages/service-account-page.ts index f6fbbe7f171..52a6615d3e2 100644 --- a/frontend/e2e/pages/service-account-page.ts +++ b/frontend/e2e/pages/service-account-page.ts @@ -2,8 +2,6 @@ import { expect, type Locator } from '@playwright/test'; import BasePage from './base-page'; -const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - export class ServiceAccountPage extends BasePage { private readonly actionsMenuButton: Locator = this.page.getByTestId('actions-menu-button'); private readonly impersonateAction: Locator = this.page.getByRole('menuitem', { @@ -12,9 +10,7 @@ export class ServiceAccountPage extends BasePage { async navigateToDetails(namespace: string, name: string): Promise { await this.goTo(`/k8s/ns/${namespace}/~v1~ServiceAccount/${name}`); - await expect( - this.page.getByRole('heading', { name: new RegExp(`ServiceAccount.*${escapeRegExp(name)}`) }), - ).toBeVisible({ + await expect(this.page.getByRole('heading', { level: 1 }).filter({ hasText: name })).toBeVisible({ timeout: 60_000, }); } From 74988c2b5dcf19413ef3bfa456f9b3733e789967 Mon Sep 17 00:00:00 2001 From: Ben Dronen Date: Mon, 24 Aug 2026 09:24:33 -0400 Subject: [PATCH 6/7] RFE-9146: pick ns/sa from dropdown Signed-off-by: Ben Dronen --- frontend/e2e/pages/masthead-page.ts | 25 +++- .../__tests__/impersonate-user-modal.spec.tsx | 137 +++++++++++++++--- .../modals/impersonate-user-modal.tsx | 109 +++++++------- frontend/public/locales/en/public.json | 6 +- 4 files changed, 198 insertions(+), 79 deletions(-) diff --git a/frontend/e2e/pages/masthead-page.ts b/frontend/e2e/pages/masthead-page.ts index 873284ad9e7..3aca105b7f3 100644 --- a/frontend/e2e/pages/masthead-page.ts +++ b/frontend/e2e/pages/masthead-page.ts @@ -14,11 +14,11 @@ export class MastheadPage extends BasePage { private readonly serviceAccountRadio: Locator = this.page.getByTestId( 'impersonate-kind-service-account', ); - private readonly serviceAccountNamespaceInput: Locator = this.page.getByTestId( - 'service-account-namespace-input', + private readonly serviceAccountNamespaceDropdown: Locator = this.page.getByTestId( + 'service-account-namespace-dropdown', ); - private readonly serviceAccountNameInput: Locator = this.page.getByTestId( - 'service-account-name-input', + private readonly serviceAccountNameDropdown: Locator = this.page.getByTestId( + 'service-account-name-dropdown', ); private readonly groupInput: Locator = this.page.getByPlaceholder('Enter groups'); private readonly impersonateButton: Locator = this.page.getByTestId('impersonate-button'); @@ -63,6 +63,15 @@ export class MastheadPage extends BasePage { await this.page.mouse.click(20, 20); } + private async fillConsoleSelectSearch(text: string): Promise { + await this.page.getByTestId('console-select-search-input').locator('input').fill(text); + } + + private async selectConsoleSelectOption(label: string): Promise { + const menuList = this.page.getByTestId('console-select-menu-list'); + await this.robustClick(menuList.getByText(label, { exact: true }).first()); + } + async impersonateUser(username: string, groups: string[] = []): Promise { await this.openUserDropdown(); await this.robustClick(this.impersonateUserItem); @@ -79,8 +88,12 @@ export class MastheadPage extends BasePage { await this.openUserDropdown(); await this.robustClick(this.impersonateUserItem); await this.robustClick(this.serviceAccountRadio); - await this.serviceAccountNamespaceInput.fill(namespace); - await this.serviceAccountNameInput.fill(name); + await this.robustClick(this.serviceAccountNamespaceDropdown); + await this.fillConsoleSelectSearch(namespace); + await this.selectConsoleSelectOption(namespace); + await this.robustClick(this.serviceAccountNameDropdown); + await this.fillConsoleSelectSearch(name); + await this.selectConsoleSelectOption(name); await this.selectGroups(groups); await this.robustClick(this.impersonateButton); } diff --git a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx index 5505bd172bd..3bb8a2b37f1 100644 --- a/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx +++ b/frontend/public/components/modals/__tests__/impersonate-user-modal.spec.tsx @@ -9,6 +9,53 @@ jest.mock('../../utils/k8s-watch-hook', () => ({ useK8sWatchResource: jest.fn(), })); +// Stub NsDropdown: emits a fixed namespace selection on click +jest.mock('../../utils/list-dropdown', () => ({ + NsDropdown: ({ + selectedKey, + onChange, + dataTest, + }: { + selectedKey?: string; + dataTest?: string; + onChange: (key: string, kind?: string, resource?: { metadata: { name: string } }) => void; + }) => ( + + ), +})); + +// Stub ResourceDropdown: emits a fixed service account selection on click +jest.mock('@console/shared/src/components/dropdown/ResourceDropdown', () => ({ + ResourceDropdown: ({ + selectedKey, + onChange, + dataTest, + disabled, + placeholder, + }: { + selectedKey?: string | null; + dataTest?: string; + disabled?: boolean; + placeholder?: string; + onChange: (key: string, name?: string, resource?: { metadata: { name: string } }) => void; + }) => ( + + ), +})); + const mockGroups: GroupKind[] = [ { apiVersion: 'user.openshift.io/v1', @@ -42,14 +89,35 @@ const mockGroups: GroupKind[] = [ }, ]; +const mockServiceAccounts = [ + { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { name: 'builder', namespace: 'test-ns', uid: 'sa-1', resourceVersion: '1' }, + }, + { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { name: 'deployer', namespace: 'test-ns', uid: 'sa-2', resourceVersion: '1' }, + }, +]; + describe('ImpersonateUserModal', () => { const mockOnClose = jest.fn(); const mockOnImpersonate = jest.fn(); beforeEach(() => { jest.clearAllMocks(); - // Default mock: groups loaded successfully - (useK8sWatchResource as jest.Mock).mockReturnValue([mockGroups, true, null]); + // Default mock: groups and service accounts loaded successfully + (useK8sWatchResource as jest.Mock).mockImplementation((resource) => { + if (!resource) { + return [[], true, null]; + } + if (resource.groupVersionKind?.kind === 'ServiceAccount') { + return [mockServiceAccounts, true, null]; + } + return [mockGroups, true, null]; + }); }); describe('Basic Rendering', () => { @@ -221,8 +289,10 @@ describe('ImpersonateUserModal', () => { ); await user.click(screen.getByTestId('impersonate-kind-service-account')); - await user.type(screen.getByTestId('service-account-namespace-input'), 'test-ns'); - await user.type(screen.getByTestId('service-account-name-input'), 'builder'); + + // Select namespace and service account from the dropdowns + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + await user.click(screen.getByTestId('service-account-name-dropdown')); const groupInput = screen.getByPlaceholderText('Enter groups'); await user.click(groupInput); @@ -239,28 +309,57 @@ describe('ImpersonateUserModal', () => { }); }); - it('should reject invalid service account namespace and name values', async () => { + it('should disable the service account name dropdown until a namespace is selected', async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByTestId('impersonate-kind-service-account')); - await user.type(screen.getByTestId('service-account-namespace-input'), 'Invalid_Namespace'); - await user.type(screen.getByTestId('service-account-name-input'), 'Builder'); - await user.click(screen.getByTestId('impersonate-button')); + expect(screen.getByTestId('service-account-name-dropdown')).toBeDisabled(); - expect( - screen.getByText( - 'Service account namespace must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number.', - ), - ).toBeVisible(); - expect( - screen.getByText( - 'Service account name must contain only lowercase letters, numbers, hyphens, and dots, and must start and end with a letter or number.', - ), - ).toBeVisible(); - expect(mockOnImpersonate).not.toHaveBeenCalled(); + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + expect(screen.getByTestId('service-account-name-dropdown')).toBeEnabled(); + }); + + it('should clear the selected service account when the namespace changes', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId('impersonate-kind-service-account')); + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + + const nameDropdown = screen.getByTestId('service-account-name-dropdown'); + await user.click(nameDropdown); + expect(nameDropdown).toHaveTextContent('builder'); + + // Selecting the namespace again resets the previously selected service account + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + expect(nameDropdown).toHaveTextContent('Select a service account'); + }); + + it('should watch service accounts for the selected namespace only', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId('impersonate-kind-service-account')); + + // No cluster-wide watch before a namespace is chosen + expect(useK8sWatchResource).toHaveBeenLastCalledWith(null); + + await user.click(screen.getByTestId('service-account-namespace-dropdown')); + + expect(useK8sWatchResource).toHaveBeenLastCalledWith( + expect.objectContaining({ + groupVersionKind: expect.objectContaining({ kind: 'ServiceAccount' }), + namespace: 'test-ns', + isList: true, + }), + ); }); it('should close modal after successful submission', async () => { diff --git a/frontend/public/components/modals/impersonate-user-modal.tsx b/frontend/public/components/modals/impersonate-user-modal.tsx index c4f0d71be6c..0e23ef3750a 100644 --- a/frontend/public/components/modals/impersonate-user-modal.tsx +++ b/frontend/public/components/modals/impersonate-user-modal.tsx @@ -30,22 +30,15 @@ import { } from '@patternfly/react-core'; import { RhUiCloseIcon, RhUiErrorFillIcon } from '@patternfly/react-icons'; import { useTranslation } from 'react-i18next'; -import { GroupModel } from '../../models'; -import type { GroupKind } from '../../module/k8s'; +import { ResourceDropdown } from '@console/shared/src/components/dropdown/ResourceDropdown'; +import { GroupModel, ServiceAccountModel } from '../../models'; +import type { GroupKind, K8sResourceKind } from '../../module/k8s'; import { FieldLevelHelp } from '../utils/field-level-help'; import { useK8sWatchResource } from '../utils/k8s-watch-hook'; +import { NsDropdown } from '../utils/list-dropdown'; const SELECT_ALL_KEY = '__select_all__'; const MAX_VISIBLE_CHIPS = 5; -const DNS_LABEL_MAX_LENGTH = 63; -const DNS_SUBDOMAIN_MAX_LENGTH = 253; -const DNS_LABEL_REGEXP = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/; - -const isDNS1123Label = (value: string): boolean => - value.length <= DNS_LABEL_MAX_LENGTH && DNS_LABEL_REGEXP.test(value); - -const isDNS1123Subdomain = (value: string): boolean => - value.length <= DNS_SUBDOMAIN_MAX_LENGTH && value.split('.').every(isDNS1123Label); type ImpersonateSubjectKind = 'User' | 'ServiceAccount'; @@ -95,6 +88,35 @@ export const ImpersonateUserModal: FC = ({ return groups.map((group) => group.metadata.name).sort(); }, [groups, groupsLoaded, groupsLoadError]); + // Fetch available service accounts from the selected namespace. + // Pass `null` until a namespace is chosen to avoid a cluster-wide watch. + const [watchedServiceAccounts, serviceAccountsLoaded, serviceAccountsLoadError] = + useK8sWatchResource( + serviceAccountNamespace + ? { + groupVersionKind: { + group: ServiceAccountModel.apiGroup, + version: ServiceAccountModel.apiVersion, + kind: ServiceAccountModel.kind, + }, + namespace: serviceAccountNamespace, + isList: true, + } + : null, + ); + + const serviceAccounts = useMemo( + () => [ + { + data: watchedServiceAccounts ?? [], + loaded: serviceAccountsLoaded, + loadError: serviceAccountsLoadError, + kind: ServiceAccountModel.kind, + }, + ], + [watchedServiceAccounts, serviceAccountsLoaded, serviceAccountsLoadError], + ); + const handleClose = useCallback(() => { setImpersonateKind('User'); setUsername(prefilledUsername); @@ -172,32 +194,18 @@ export const ImpersonateUserModal: FC = ({ } if (impersonateKind === 'ServiceAccount') { - const trimmedNamespace = serviceAccountNamespace.trim(); - const trimmedName = serviceAccountName.trim(); + // Namespace and name are selected from existing resources, so only + // presence is validated as a safeguard. let isValid = true; - if (!trimmedNamespace) { + if (!serviceAccountNamespace.trim()) { setServiceAccountNamespaceError(t('Service account namespace is required')); isValid = false; - } else if (!isDNS1123Label(trimmedNamespace)) { - setServiceAccountNamespaceError( - t( - 'Service account namespace must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number.', - ), - ); - isValid = false; } - if (!trimmedName) { + if (!serviceAccountName.trim()) { setServiceAccountNameError(t('Service account name is required')); isValid = false; - } else if (!isDNS1123Subdomain(trimmedName)) { - setServiceAccountNameError( - t( - 'Service account name must contain only lowercase letters, numbers, hyphens, and dots, and must start and end with a letter or number.', - ), - ); - isValid = false; } return isValid; @@ -389,18 +397,17 @@ export const ImpersonateUserModal: FC = ({ fieldId="impersonate-service-account-namespace" isRequired > - { - setServiceAccountNamespace(value); + { + setServiceAccountNamespace(resource?.metadata?.name ?? ''); setServiceAccountNamespaceError(''); + // The service accounts of the previously selected namespace no longer apply + setServiceAccountName(''); + setServiceAccountNameError(''); }} - placeholder={t('Enter a namespace')} - data-test="service-account-namespace-input" - validated={serviceAccountNamespaceError ? 'error' : 'default'} - aria-label={t('Service account namespace to impersonate')} + dataTest="service-account-namespace-dropdown" /> {serviceAccountNamespaceError && ( @@ -417,18 +424,22 @@ export const ImpersonateUserModal: FC = ({ fieldId="impersonate-service-account-name" isRequired > - { - setServiceAccountName(value); + { + setServiceAccountName(key ?? ''); setServiceAccountNameError(''); }} - placeholder={t('Enter a service account name')} - data-test="service-account-name-input" - validated={serviceAccountNameError ? 'error' : 'default'} - aria-label={t('Service account name to impersonate')} + dataTest="service-account-name-dropdown" + disabled={!serviceAccountNamespace} + ariaLabel={t('Service account name to impersonate')} + isFullWidth /> {serviceAccountNameError && ( diff --git a/frontend/public/locales/en/public.json b/frontend/public/locales/en/public.json index 7f25eb811f8..dabe9c1b96d 100644 --- a/frontend/public/locales/en/public.json +++ b/frontend/public/locales/en/public.json @@ -620,8 +620,6 @@ "Emoji": "Emoji", "emoji code": "emoji code", "Enter a font size": "Enter a font size", - "Enter a namespace": "Enter a namespace", - "Enter a service account name": "Enter a service account name", "Enter a username": "Enter a username", "Enter groups": "Enter groups", "Enter name": "Enter name", @@ -1365,6 +1363,7 @@ "Select a configuration to receive updates. Updates can be configured to receive information from Red Hat or a custom update service.": "Select a configuration to receive updates. Updates can be configured to receive information from Red Hat or a custom update service.", "Select a key": "Select a key", "Select a resource": "Select a resource", + "Select a service account": "Select a service account", "Select a version": "Select a version", "Select a workload": "Select a workload", "Select all": "Select all", @@ -1398,12 +1397,9 @@ "Service": "Service", "Service account name": "Service account name", "Service account name is required": "Service account name is required", - "Service account name must contain only lowercase letters, numbers, hyphens, and dots, and must start and end with a letter or number.": "Service account name must contain only lowercase letters, numbers, hyphens, and dots, and must start and end with a letter or number.", "Service account name to impersonate": "Service account name to impersonate", "Service account namespace": "Service account namespace", "Service account namespace is required": "Service account namespace is required", - "Service account namespace must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number.": "Service account namespace must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number.", - "Service account namespace to impersonate": "Service account namespace to impersonate", "Service Account Token": "Service Account Token", "Service key": "Service key", "Service Level Agreement (SLA)": "Service Level Agreement (SLA)", From d4090a80f249270110bf262b139466e624600b6a Mon Sep 17 00:00:00 2001 From: Ben Dronen Date: Mon, 24 Aug 2026 10:34:35 -0400 Subject: [PATCH 7/7] RFE-9146: improve e2e tests Signed-off-by: Ben Dronen --- frontend/e2e/pages/base-page.ts | 18 ++++ frontend/e2e/pages/masthead-page.ts | 7 +- frontend/e2e/pages/service-account-page.ts | 1 + frontend/e2e/pages/user-page.ts | 25 ++++++ .../tests/console/app/impersonation.spec.ts | 83 +++++++++++++++++++ 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/pages/user-page.ts diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index 83467a30f24..916bafa5fde 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -121,6 +121,24 @@ export default abstract class BasePage { await this.waitForLoadingComplete(); } + protected async waitForDetailsActions(actionsButton: Locator, timeoutMs = 60_000): Promise { + // Navigating right after impersonation teardown can race the SPA reload and + // abort API discovery, leaving the resource watch stuck on "Model does not + // exist" with no auto-retry. Recover by reloading until actions render. + await expect(async () => { + const modelError = await this.page + .getByText('Model does not exist') + .isVisible() + .catch(() => false); + if (modelError) { + await this.retryOnError(); + } else { + // eslint-disable-next-line no-restricted-syntax + await actionsButton.waitFor({ state: 'visible', timeout: 5_000 }); + } + }).toPass({ timeout: timeoutMs }); + } + protected locator( selector: string, options?: { diff --git a/frontend/e2e/pages/masthead-page.ts b/frontend/e2e/pages/masthead-page.ts index 3aca105b7f3..81cdd4cd85c 100644 --- a/frontend/e2e/pages/masthead-page.ts +++ b/frontend/e2e/pages/masthead-page.ts @@ -55,8 +55,13 @@ export class MastheadPage extends BasePage { } private async selectGroups(groups: string[]): Promise { + if (groups.length === 0) { + return; + } + + // Open once; the modal keeps the selector open between selections + await this.groupInput.click(); for (const group of groups) { - await this.groupInput.click(); const groupOption = this.page.getByText(group, { exact: true }); await this.robustClick(groupOption); } diff --git a/frontend/e2e/pages/service-account-page.ts b/frontend/e2e/pages/service-account-page.ts index 52a6615d3e2..3d5232ca34e 100644 --- a/frontend/e2e/pages/service-account-page.ts +++ b/frontend/e2e/pages/service-account-page.ts @@ -13,6 +13,7 @@ export class ServiceAccountPage extends BasePage { await expect(this.page.getByRole('heading', { level: 1 }).filter({ hasText: name })).toBeVisible({ timeout: 60_000, }); + await this.waitForDetailsActions(this.actionsMenuButton); } async impersonateFromDetails(): Promise { diff --git a/frontend/e2e/pages/user-page.ts b/frontend/e2e/pages/user-page.ts new file mode 100644 index 00000000000..64c2c970f7c --- /dev/null +++ b/frontend/e2e/pages/user-page.ts @@ -0,0 +1,25 @@ +import type { Locator } from '@playwright/test'; + +import { expect } from '../fixtures'; + +import BasePage from './base-page'; + +export class UserPage extends BasePage { + private readonly actionsMenuButton: Locator = this.page.getByTestId('actions-menu-button'); + private readonly impersonateAction: Locator = this.page.getByRole('menuitem', { + name: /Impersonate user/, + }); + + async navigateToDetails(name: string): Promise { + await this.goTo(`/k8s/cluster/user.openshift.io~v1~User/${name}`); + await expect(this.page.getByRole('heading', { level: 1 }).filter({ hasText: name })).toBeVisible({ + timeout: 60_000, + }); + await this.waitForDetailsActions(this.actionsMenuButton); + } + + async impersonateFromDetails(): Promise { + await this.robustClick(this.actionsMenuButton); + await this.robustClick(this.impersonateAction); + } +} diff --git a/frontend/e2e/tests/console/app/impersonation.spec.ts b/frontend/e2e/tests/console/app/impersonation.spec.ts index 6a364775adc..4fb912fc1a4 100644 --- a/frontend/e2e/tests/console/app/impersonation.spec.ts +++ b/frontend/e2e/tests/console/app/impersonation.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '../../../fixtures'; import { warmupSPA } from '../../../pages/base-page'; import { MastheadPage } from '../../../pages/masthead-page'; import { ServiceAccountPage } from '../../../pages/service-account-page'; +import { UserPage } from '../../../pages/user-page'; test.describe('Impersonation', { tag: ['@admin'] }, () => { test('can impersonate users and service accounts with groups', async ({ @@ -13,10 +14,12 @@ test.describe('Impersonation', { tag: ['@admin'] }, () => { const namespace = `sa-impersonation-${suffix}`; const serviceAccountName = `impersonation-target-${suffix}`; const groupName = `impersonation-group-${suffix}`; + const secondGroupName = `impersonation-group-two-${suffix}`; const username = `impersonation-user-${suffix}`; const serviceAccountUsername = `system:serviceaccount:${namespace}:${serviceAccountName}`; const masthead = new MastheadPage(page); const serviceAccountPage = new ServiceAccountPage(page); + const userPage = new UserPage(page); await test.step('Create service account and group', async () => { await k8sClient.createNamespace(namespace); @@ -37,6 +40,34 @@ test.describe('Impersonation', { tag: ['@admin'] }, () => { }, }); cleanup.trackClusterCustomResource(groupName, 'user.openshift.io', 'v1', 'groups', 'Group'); + await k8sClient.customObjectsApi.createClusterCustomObject({ + group: 'user.openshift.io', + version: 'v1', + plural: 'groups', + body: { + apiVersion: 'user.openshift.io/v1', + kind: 'Group', + metadata: { name: secondGroupName }, + }, + }); + cleanup.trackClusterCustomResource( + secondGroupName, + 'user.openshift.io', + 'v1', + 'groups', + 'Group', + ); + await k8sClient.customObjectsApi.createClusterCustomObject({ + group: 'user.openshift.io', + version: 'v1', + plural: 'users', + body: { + apiVersion: 'user.openshift.io/v1', + kind: 'User', + metadata: { name: username }, + }, + }); + cleanup.trackClusterCustomResource(username, 'user.openshift.io', 'v1', 'users', 'User'); }); await test.step('Impersonate user from masthead modal', async () => { @@ -69,6 +100,23 @@ test.describe('Impersonation', { tag: ['@admin'] }, () => { }); }); + await test.step('Impersonate user with multiple groups from masthead modal', async () => { + await masthead.impersonateUser(username, [groupName, secondGroupName]); + await expect(page.getByText(`You are impersonating user ${username}`)).toBeVisible({ + timeout: 60_000, + }); + await expect( + page.getByText(`with groups: ${groupName}, ${secondGroupName}`), + ).toBeVisible({ timeout: 60_000 }); + }); + + await test.step('Stop impersonating user with multiple groups', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating user ${username}`)).toBeHidden({ + timeout: 60_000, + }); + }); + await test.step('Impersonate service account from masthead modal', async () => { await masthead.impersonateServiceAccount(namespace, serviceAccountName); await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ @@ -98,6 +146,26 @@ test.describe('Impersonation', { tag: ['@admin'] }, () => { }); }); + await test.step('Impersonate service account with multiple groups from masthead modal', async () => { + await masthead.impersonateServiceAccount(namespace, serviceAccountName, [ + groupName, + secondGroupName, + ]); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeVisible({ + timeout: 60_000, + }); + await expect( + page.getByText(`with groups: ${groupName}, ${secondGroupName}`), + ).toBeVisible({ timeout: 60_000 }); + }); + + await test.step('Stop impersonating service account with multiple groups', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ + timeout: 60_000, + }); + }); + await test.step('Impersonate service account from resource details action', async () => { await serviceAccountPage.navigateToDetails(namespace, serviceAccountName); await serviceAccountPage.impersonateFromDetails(); @@ -105,5 +173,20 @@ test.describe('Impersonation', { tag: ['@admin'] }, () => { timeout: 60_000, }); }); + + await test.step('Stop impersonating service account', async () => { + await masthead.stopImpersonating(); + await expect(page.getByText(`You are impersonating ServiceAccount ${serviceAccountUsername}`)).toBeHidden({ + timeout: 60_000, + }); + }); + + await test.step('Impersonate user from resource details action', async () => { + await userPage.navigateToDetails(username); + await userPage.impersonateFromDetails(); + await expect(page.getByText(`You are impersonating User ${username}`)).toBeVisible({ + timeout: 60_000, + }); + }); }); });