Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions frontend/e2e/pages/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,24 @@ export default abstract class BasePage {
await this.waitForLoadingComplete();
}

protected async waitForDetailsActions(actionsButton: Locator, timeoutMs = 60_000): Promise<void> {
// 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?: {
Expand Down
76 changes: 76 additions & 0 deletions frontend/e2e/pages/masthead-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ 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 usernameInput: Locator = this.page.getByTestId('username-input');
private readonly serviceAccountRadio: Locator = this.page.getByTestId(
'impersonate-kind-service-account',
);
private readonly serviceAccountNamespaceDropdown: Locator = this.page.getByTestId(
'service-account-namespace-dropdown',
);
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');
private readonly copyLoginCommandLink: Locator = this.page
.getByTestId('copy-login-command')
.locator('a');
Expand Down Expand Up @@ -40,6 +54,68 @@ export class MastheadPage extends BasePage {
await this.userDropdownToggle.click();
}

private async selectGroups(groups: string[]): Promise<void> {
if (groups.length === 0) {
return;
}

// Open once; the modal keeps the selector open between selections
await this.groupInput.click();
for (const group of groups) {
const groupOption = this.page.getByText(group, { exact: true });
await this.robustClick(groupOption);
}
await this.page.mouse.click(20, 20);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private async fillConsoleSelectSearch(text: string): Promise<void> {
await this.page.getByTestId('console-select-search-input').locator('input').fill(text);
}

private async selectConsoleSelectOption(label: string): Promise<void> {
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<void> {
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<void> {
await this.openUserDropdown();
await this.robustClick(this.impersonateUserItem);
await this.robustClick(this.serviceAccountRadio);
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);
}

async stopImpersonating(): Promise<void> {
const currentURL = this.page.url();
await this.openUserDropdown();
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<boolean> {
return this.page.evaluate(() => {
const w = window as Window & { SERVER_FLAGS?: { authDisabled?: boolean } };
Expand Down
23 changes: 23 additions & 0 deletions frontend/e2e/pages/service-account-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, 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<void> {
await this.goTo(`/k8s/ns/${namespace}/~v1~ServiceAccount/${name}`);
await expect(this.page.getByRole('heading', { level: 1 }).filter({ hasText: name })).toBeVisible({
timeout: 60_000,
});
await this.waitForDetailsActions(this.actionsMenuButton);
}

async impersonateFromDetails(): Promise<void> {
await this.robustClick(this.actionsMenuButton);
await this.robustClick(this.impersonateAction);
}
}
25 changes: 25 additions & 0 deletions frontend/e2e/pages/user-page.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await this.robustClick(this.actionsMenuButton);
await this.robustClick(this.impersonateAction);
}
}
192 changes: 192 additions & 0 deletions frontend/e2e/tests/console/app/impersonation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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 ({
page,
cleanup,
k8sClient,
}) => {
const suffix = Date.now();
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);
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 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 () => {
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 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({
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 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();
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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

122, 129, 136, 144, 154, 164, 172, 179 check for "You are impersonating ServiceAccount {username}" on page. if this reflects actual banner text, is it same "ServiceAccount" one-word casing issue in modal radio label? confirm this is literal rendered text and, if so, should it --> "service account" (lowercase + space)?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can leave this one since the i18n string refers to the specific k8s technical name

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,
});
});
});
});
2 changes: 1 addition & 1 deletion frontend/packages/console-app/console-extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2686,7 +2686,7 @@
"version": "v1",
"kind": "ServiceAccount"
},
"provider": { "$codeRef": "defaultProvider.useDefaultActionsProvider" }
"provider": { "$codeRef": "serviceAccountProvider.useServiceAccountActionsProvider" }
}
},
{
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/console-app/locales/en/console-app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Impersonate service account {{name}}" is lowercase. line 356 capitalizes "Impersonate Group {{name}}". make capitalization consistent? (Group --> group?) @logonoff

"Impersonate user {{name}}": "Impersonate user {{name}}",
"In progress": "In progress",
"In progress ({{statusCount, number}})": "In progress ({{statusCount, number}})",
Expand Down
3 changes: 2 additions & 1 deletion frontend/packages/console-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Loading