diff --git a/frontend/src/components/pages/security/roles/role-create-dialog.tsx b/frontend/src/components/pages/security/roles/role-create-dialog.tsx index 70a9f23649..947988ce17 100644 --- a/frontend/src/components/pages/security/roles/role-create-dialog.tsx +++ b/frontend/src/components/pages/security/roles/role-create-dialog.tsx @@ -97,7 +97,11 @@ export const RoleCreateDialog = ({ open, onOpenChange }: RoleCreateDialogProps) Cancel - + Create diff --git a/frontend/src/components/pages/security/roles/role-detail-page-new.tsx b/frontend/src/components/pages/security/roles/role-detail-page-new.tsx index 4bf9098dc3..e0ce091ef4 100644 --- a/frontend/src/components/pages/security/roles/role-detail-page-new.tsx +++ b/frontend/src/components/pages/security/roles/role-detail-page-new.tsx @@ -121,10 +121,10 @@ export const RoleDetailPageNew = () => { className="w-56" clearable={false} disabled={isSubmitting} + inputTestId="add-principal-combobox" onChange={addMember} options={availablePrincipalOptions} placeholder="Add a principal..." - testId="add-principal-combobox" value="" /> } diff --git a/frontend/src/components/pages/security/shared/acls-card.tsx b/frontend/src/components/pages/security/shared/acls-card.tsx index 1cdd5bdc94..6a54533c4c 100644 --- a/frontend/src/components/pages/security/shared/acls-card.tsx +++ b/frontend/src/components/pages/security/shared/acls-card.tsx @@ -302,7 +302,7 @@ export const AclsCard = ({ acls, principal, isLoading }: AclsCardProps) => { )} {principal && ( - setGrantAllOpen(true)} variant="outline"> + setGrantAllOpen(true)} testId="allow-all-operations-button" variant="outline"> Allow all operations )} @@ -374,7 +374,12 @@ export const AclsCard = ({ acls, principal, isLoading }: AclsCardProps) => { setGrantAllOpen(false)} type="button" variant="outline"> Cancel - + Confirm diff --git a/frontend/src/components/pages/security/tabs/users-tab-new.tsx b/frontend/src/components/pages/security/tabs/users-tab-new.tsx index 19f995c27f..94812e9cd9 100644 --- a/frontend/src/components/pages/security/tabs/users-tab-new.tsx +++ b/frontend/src/components/pages/security/tabs/users-tab-new.tsx @@ -556,12 +556,18 @@ const UserActions = ({ user }: { user: PrincipalEntry }) => { - + { e.stopPropagation(); setIsChangePasswordModalOpen(true); @@ -571,6 +577,7 @@ const UserActions = ({ user }: { user: PrincipalEntry }) => { Change password { e.stopPropagation(); setIsDeleteModalOpen(true); diff --git a/frontend/src/components/pages/security/users/add-acl-dialog.tsx b/frontend/src/components/pages/security/users/add-acl-dialog.tsx index 0b7136236d..054197887a 100644 --- a/frontend/src/components/pages/security/users/add-acl-dialog.tsx +++ b/frontend/src/components/pages/security/users/add-acl-dialog.tsx @@ -459,7 +459,7 @@ export const AddAclDialog = ({ open, onOpenChange, principal }: AddAclDialogProp Cancel - + Add ACL diff --git a/frontend/src/components/pages/security/users/user-details-new.tsx b/frontend/src/components/pages/security/users/user-details-new.tsx index e2b57ea09b..45b70654a7 100644 --- a/frontend/src/components/pages/security/users/user-details-new.tsx +++ b/frontend/src/components/pages/security/users/user-details-new.tsx @@ -123,12 +123,16 @@ export const UserDetailsPageNew = ({ userName }: UserDetailsPageProps) => { - setIsChangePasswordModalOpen(true)} variant="outline"> + setIsChangePasswordModalOpen(true)} + variant="outline" + > Change Password {Boolean(isServiceAccount) && ( - setIsDeleteModalOpen(true)} variant="destructive"> + setIsDeleteModalOpen(true)} variant="destructive"> Delete User diff --git a/frontend/src/components/pages/security/users/user-edit-modals.tsx b/frontend/src/components/pages/security/users/user-edit-modals.tsx index 2824d6d5b6..3b2c257975 100644 --- a/frontend/src/components/pages/security/users/user-edit-modals.tsx +++ b/frontend/src/components/pages/security/users/user-edit-modals.tsx @@ -85,9 +85,9 @@ export const ChangePasswordModal = ({ userName, isOpen, setIsOpen }: ChangePassw setPassword(e.target.value)} + testId="create-user-password" type="password" value={password} /> @@ -150,6 +150,7 @@ export const ChangePasswordModal = ({ userName, isOpen, setIsOpen }: ChangePassw Cancel ) : undefined diff --git a/frontend/tests/test-variant-console-enterprise/security/security-roles.spec.ts b/frontend/tests/test-variant-console-enterprise/security/security-roles.spec.ts new file mode 100644 index 0000000000..41fd537de7 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise/security/security-roles.spec.ts @@ -0,0 +1,106 @@ +import { generateSecurityName, SecurityPageNew } from './utils/security-page-new'; +import { expect, test } from '../fixtures'; + +test.use({ featureFlags: { enableNewSecurityPage: true } }); + +test.describe('Security › Roles', () => { + test('create role appears in list', async ({ page }) => { + const name = generateSecurityName('role'); + const sp = new SecurityPageNew(page); + + await sp.createRole(name); + + await test.step('verify role in list', async () => { + await sp.gotoRoles(); + await sp.filterRoles(name); + await expect(page.getByTestId(`role-list-item-${name}`)).toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteRoleFromList(name); + }); + + test('filter roles by name regexp', async ({ page }) => { + const sp = new SecurityPageNew(page); + const suffix = Math.random().toString(36).substring(2, 6); + const nameA = `e2e-role-filter-${suffix}-a`; + const nameB = `e2e-role-filter-${suffix}-b`; + const nameC = `e2e-role-filter-${suffix}-c`; + + await sp.createRole(nameA); + await sp.createRole(nameB); + await sp.createRole(nameC); + + await sp.gotoRoles(); + await sp.filterRoles(`e2e-role-filter-${suffix}-[ab]`); + + await expect(page.getByTestId(`role-list-item-${nameA}`)).toBeVisible(); + await expect(page.getByTestId(`role-list-item-${nameB}`)).toBeVisible(); + await expect(page.getByTestId(`role-list-item-${nameC}`)).not.toBeVisible(); + + await sp.deleteRoleFromList(nameA); + await sp.deleteRoleFromList(nameB); + await sp.deleteRoleFromList(nameC); + }); + + test('navigate to role details', async ({ page }) => { + const name = generateSecurityName('role'); + const sp = new SecurityPageNew(page); + + await sp.createRole(name); + + await test.step('navigate via list link', async () => { + await sp.gotoRoles(); + await sp.filterRoles(name); + await page.getByTestId(`role-list-item-${name}`).click(); + await page.waitForURL('**/security/roles/**/details'); + await expect(page.getByRole('heading', { name })).toBeVisible(); + }); + + await sp.deleteRoleFromList(name); + }); + + test('add principal to role', async ({ page }) => { + const name = generateSecurityName('role'); + const sp = new SecurityPageNew(page); + + await sp.createRole(name); + await sp.gotoRoleDetails(name); + + await test.step('add e2euser as principal', async () => { + await sp.addPrincipalToRole('e2euser'); + await expect(page.getByTestId('remove-user-e2euser-button')).toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteRoleFromList(name); + }); + + test('remove principal from role', async ({ page }) => { + const name = generateSecurityName('role'); + const sp = new SecurityPageNew(page); + + await sp.createRole(name); + await sp.gotoRoleDetails(name); + + await sp.addPrincipalToRole('e2euser'); + await expect(page.getByTestId('remove-user-e2euser-button')).toBeVisible(); + + await test.step('remove e2euser from role', async () => { + await sp.removePrincipalFromRole('e2euser'); + await expect(page.getByTestId('remove-user-e2euser-button')).not.toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteRoleFromList(name); + }); + + test('delete role from list', async ({ page }) => { + const name = generateSecurityName('role'); + const sp = new SecurityPageNew(page); + + await sp.createRole(name); + await sp.deleteRoleFromList(name); + + await sp.gotoRoles(); + await sp.filterRoles(name); + await expect(page.getByTestId(`role-list-item-${name}`)).not.toBeVisible(); + }); +}); diff --git a/frontend/tests/test-variant-console-enterprise/security/security-users-advanced.spec.ts b/frontend/tests/test-variant-console-enterprise/security/security-users-advanced.spec.ts new file mode 100644 index 0000000000..52cd09d8e5 --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise/security/security-users-advanced.spec.ts @@ -0,0 +1,153 @@ +/** biome-ignore-all lint/performance/useTopLevelRegex: this is a test */ +import { generateSecurityName, SecurityPageNew } from './utils/security-page-new'; +import { expect, test } from '../fixtures'; + +test.use({ featureFlags: { enableNewSecurityPage: true } }); + +test.describe('Security › Users › advanced', () => { + // ── Role assignment during user creation ───────────────────────────────── + + test('role selected at creation appears in user details', async ({ page }) => { + const roleName = generateSecurityName('role'); + const userName = generateSecurityName('usr'); + const sp = new SecurityPageNew(page); + + await sp.createRole(roleName); + await sp.createUserWithRole(userName, roleName); + + await test.step('verify role shown in user details Roles card', async () => { + await sp.gotoUserDetails(userName); + await expect(page.getByTestId(`role-name-${roleName}`)).toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteUserFromDetails(userName); + await sp.deleteRoleFromList(roleName); + }); + + // ── Allow all operations ────────────────────────────────────────────────── + + test('Allow all operations creates ACLs for all resource types', async ({ page }) => { + const userName = generateSecurityName('usr'); + const sp = new SecurityPageNew(page); + + await sp.createUser(userName); + await sp.gotoUserDetails(userName); + + await sp.allowAllOperations(); + + await test.step('verify ACL rows appear for all resources', async () => { + // Wait for at least 4 Allow permission cells (Topic/Group/Cluster/TransactionalId, + // plus Subject/SchemaRegistry when schemaRegistryACLApi is enabled) + const allowCells = page.getByRole('cell', { name: 'Allow' }); + await expect(allowCells.first()).toBeVisible({ timeout: 10_000 }); + await expect(allowCells.nth(3)).toBeVisible(); + // Each row should show "All" operation + await expect(page.getByRole('cell', { name: 'All' }).first()).toBeVisible(); + }); + + await sp.deleteUserFromDetails(userName); + }); + + // ── Assign / remove role from user details page ─────────────────────────── + + test('assign role from user details', async ({ page }) => { + const roleName = generateSecurityName('role'); + const userName = generateSecurityName('usr'); + const sp = new SecurityPageNew(page); + + await sp.createRole(roleName); + await sp.createUser(userName); + await sp.gotoUserDetails(userName); + + await sp.assignRoleFromDetails(roleName); + + await test.step('verify role row visible', async () => { + await expect(page.getByTestId(`role-name-${roleName}`)).toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteUserFromDetails(userName); + await sp.deleteRoleFromList(roleName); + }); + + test('remove role from user details', async ({ page }) => { + const roleName = generateSecurityName('role'); + const userName = generateSecurityName('usr'); + const sp = new SecurityPageNew(page); + + await sp.createRole(roleName); + await sp.createUser(userName); + await sp.gotoUserDetails(userName); + await sp.assignRoleFromDetails(roleName); + + await sp.removeRoleFromDetails(roleName); + + await test.step('verify role row gone', async () => { + await expect(page.getByTestId(`role-name-${roleName}`)).not.toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteUserFromDetails(userName); + await sp.deleteRoleFromList(roleName); + }); + + // ── Role membership visible in Permissions page ─────────────────────────── + + test('user with role shows role-based ACLs in Permissions page', async ({ page }) => { + const roleName = generateSecurityName('role'); + const userName = generateSecurityName('usr'); + const topicName = generateSecurityName('topic'); + const sp = new SecurityPageNew(page); + + await sp.createRole(roleName); + + // Add an ACL to the role so it has something to show + await sp.gotoRoleDetails(roleName); + await test.step('add ACL to role', async () => { + await page.getByTestId('add-acl-button').click(); + await page.getByRole('dialog', { name: 'Add ACL' }).waitFor({ state: 'visible' }); + await page.getByPlaceholder('e.g. my-topic').fill(topicName); + await page.getByTestId('add-acl-submit-button').click(); + await page.getByRole('dialog').waitFor({ state: 'hidden' }); + }); + + // Create user and assign the role + await sp.createUser(userName); + await sp.gotoUserDetails(userName); + await sp.assignRoleFromDetails(roleName); + + await test.step('permissions page shows user row', async () => { + await page.goto('/security/permissions', { waitUntil: 'domcontentloaded' }); + await page.getByTestId(`row-${userName}`).waitFor({ state: 'visible', timeout: 15_000 }); + await expect(page.getByTestId(`row-${userName}`)).toBeVisible(); + }); + + await sp.deleteUserFromDetails(userName); + await sp.deleteRoleFromList(roleName); + }); + + // ── SASL mechanism is preserved in confirmation step ───────────────────── + + test('SCRAM-SHA-512 mechanism set during creation shows in user list', async ({ page }) => { + const userName = generateSecurityName('usr'); + const sp = new SecurityPageNew(page); + + await sp.gotoUsers(); + await page.getByTestId('create-user-button').click(); + await page.getByTestId('create-user-name').waitFor({ state: 'visible' }); + await page.getByLabel('Username').fill(userName); + + // Switch from default SCRAM-SHA-256 to SCRAM-SHA-512 + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'SCRAM-SHA-512' }).click(); + + await page.getByTestId('create-user-submit').click(); + await page.getByTestId('done-button').waitFor({ state: 'visible' }); + + await test.step('mechanism label shown in user list', async () => { + await sp.gotoUsers(); + await sp.filterUsers(userName); + await expect(page.getByText('SCRAM-SHA-512')).toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteUserFromList(userName); + }); +}); diff --git a/frontend/tests/test-variant-console-enterprise/security/security-users.spec.ts b/frontend/tests/test-variant-console-enterprise/security/security-users.spec.ts new file mode 100644 index 0000000000..61f21722fe --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise/security/security-users.spec.ts @@ -0,0 +1,112 @@ +import { generateSecurityName, SecurityPageNew } from './utils/security-page-new'; +import { expect, test } from '../fixtures'; + +test.use({ featureFlags: { enableNewSecurityPage: true } }); + +test.describe('Security › Users', () => { + test('create user appears in list', async ({ page }) => { + const name = generateSecurityName('usr'); + const sp = new SecurityPageNew(page); + + await sp.createUser(name); + + await test.step('verify user link in list', async () => { + await sp.gotoUsers(); + await expect(page.locator(`a[href='/security/users/${name}/details']`)).toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteUserFromList(name); + }); + + test('filter users by name regexp', async ({ page }) => { + const sp = new SecurityPageNew(page); + const suffix = Math.random().toString(36).substring(2, 6); + const name1 = `e2e-usr-filter-${suffix}-a`; + const name2 = `e2e-usr-filter-${suffix}-b`; + const name3 = `e2e-usr-filter-${suffix}-c`; + + await sp.createUser(name1); + await sp.createUser(name2); + await sp.createUser(name3); + + await sp.gotoUsers(); + await sp.filterUsers(`e2e-usr-filter-${suffix}-[ab]`); + + await expect(page.locator(`a[href='/security/users/${name1}/details']`)).toBeVisible(); + await expect(page.locator(`a[href='/security/users/${name2}/details']`)).toBeVisible(); + await expect(page.locator(`a[href='/security/users/${name3}/details']`)).not.toBeVisible(); + + await sp.deleteUserFromDetails(name1); + await sp.deleteUserFromDetails(name2); + await sp.deleteUserFromDetails(name3); + }); + + test('navigate to user details', async ({ page }) => { + const name = generateSecurityName('usr'); + const sp = new SecurityPageNew(page); + + await sp.createUser(name); + + await test.step('navigate via list link', async () => { + await sp.gotoUsers(); + await sp.filterUsers(name); + await page.locator(`a[href='/security/users/${name}/details']`).click(); + await page.waitForURL(`**/security/users/${name}/details`); + // Verify the change-password button is present (confirms user details page loaded) + await expect(page.getByTestId('user-change-password-button')).toBeVisible({ timeout: 15_000 }); + }); + + await sp.deleteUserFromList(name); + }); + + test('change user password', async ({ page }) => { + const name = generateSecurityName('usr'); + const sp = new SecurityPageNew(page); + + await sp.createUser(name); + await sp.gotoUserDetails(name); + + await test.step('open change-password dialog and save', async () => { + await page.getByTestId('user-change-password-button').click(); + await sp.changePassword('NewP@ssw0rd-e2e-x'); + // Dialog closes on success; the button should be visible again + await expect(page.getByTestId('user-change-password-button')).toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteUserFromDetails(name); + }); + + test('add ACL to user from details page', async ({ page }) => { + const name = generateSecurityName('usr'); + const topicName = generateSecurityName('topic'); + const sp = new SecurityPageNew(page); + + await sp.createUser(name); + await sp.gotoUserDetails(name); + + await test.step('add ACL via dialog', async () => { + await page.getByTestId('add-acl-button').click(); + await page.getByRole('dialog', { name: 'Add ACL' }).waitFor({ state: 'visible' }); + // Resource Name input (pattern = Literal by default) + await page.getByPlaceholder('e.g. my-topic').fill(topicName); + await page.getByTestId('add-acl-submit-button').click(); + await page.getByRole('dialog').waitFor({ state: 'hidden' }); + }); + + await test.step('verify ACL row in card', async () => { + await expect(page.getByText(topicName)).toBeVisible({ timeout: 10_000 }); + }); + + await sp.deleteUserFromDetails(name); + }); + + test('delete user from list', async ({ page }) => { + const name = generateSecurityName('usr'); + const sp = new SecurityPageNew(page); + + await sp.createUser(name); + await sp.deleteUserFromList(name); + + await expect(page.locator(`a[href='/security/users/${name}/details']`)).not.toBeVisible(); + }); +}); diff --git a/frontend/tests/test-variant-console-enterprise/security/utils/security-page-new.ts b/frontend/tests/test-variant-console-enterprise/security/utils/security-page-new.ts new file mode 100644 index 0000000000..6aadaf6a0c --- /dev/null +++ b/frontend/tests/test-variant-console-enterprise/security/utils/security-page-new.ts @@ -0,0 +1,227 @@ +/** biome-ignore-all lint/performance/useTopLevelRegex: this is a test */ +import { expect, type Page, test } from '@playwright/test'; + +export function generateSecurityName(prefix: string): string { + const ts = Date.now(); + const rand = Math.random().toString(36).substring(2, 5); + return `e2e-${prefix}-${ts}-${rand}`; +} + +export class SecurityPageNew { + constructor(protected page: Page) {} + + // Retries opening a dropdown and clicking a menu item. + // TanStack Query background refetches can re-render the table, closing open dropdowns. + private async clickDropdownMenuItem(triggerTestId: string, itemTestId: string) { + for (let attempt = 0; attempt < 4; attempt++) { + await this.page.getByTestId(triggerTestId).click(); + const item = this.page.getByTestId(itemTestId); + try { + await item.waitFor({ state: 'visible', timeout: 5000 }); + await item.click({ force: true }); + return; + } catch { + // Menu closed (re-render or animation detach) — retry + } + } + throw new Error(`Could not click dropdown item ${itemTestId} after retries`); + } + + // --- Navigation --- + + async gotoUsers() { + await this.page.goto('/security/users', { waitUntil: 'domcontentloaded' }); + // Wait until the filter input is interactive, confirming React has mounted + await this.page.getByPlaceholder('Filter by name (regexp)...').waitFor({ state: 'visible' }); + } + + async gotoRoles() { + await this.page.goto('/security/roles', { waitUntil: 'domcontentloaded' }); + // Wait until the create button is visible, confirming React has mounted + await this.page.getByTestId('create-role-button').waitFor({ state: 'visible' }); + } + + async gotoUserDetails(name: string) { + await this.page.goto(`/security/users/${name}/details`, { waitUntil: 'domcontentloaded' }); + // Wait for the page content to render (gated on isUsersLoading) + await this.page.getByTestId('user-change-password-button').waitFor({ state: 'visible' }); + } + + async gotoRoleDetails(name: string) { + await this.page.goto(`/security/roles/${encodeURIComponent(name)}/details`, { + waitUntil: 'domcontentloaded', + }); + // Wait for the combobox to confirm React has mounted the role detail page + await this.page.getByTestId('add-principal-combobox').waitFor({ state: 'visible' }); + } + + // --- Users --- + + async createUser(name: string) { + return test.step(`Create user "${name}"`, async () => { + await this.gotoUsers(); + await this.page.getByTestId('create-user-button').click(); + await this.page.getByTestId('create-user-name').waitFor({ state: 'visible' }); + await this.page.getByLabel('Username').fill(name); + await this.page.getByTestId('create-user-submit').click(); + // Wait for the confirmation step (the "done-button" only renders after the user is created) + await this.page.getByTestId('done-button').waitFor({ state: 'visible' }); + }); + } + + async deleteUserFromList(name: string) { + return test.step(`Delete user "${name}" from list`, async () => { + for (let attempt = 0; attempt < 4; attempt++) { + await this.gotoUsers(); + await this.page.getByPlaceholder('Filter by name (regexp)...').fill(name); + await this.page.locator(`a[href='/security/users/${name}/details']`).waitFor({ state: 'visible' }); + await this.clickDropdownMenuItem(`user-actions-button-${name}`, `user-delete-menu-item-${name}`); + try { + const confirmInput = this.page.getByPlaceholder(`Type "${name}" to confirm`); + await confirmInput.waitFor({ state: 'visible', timeout: 5000 }); + await confirmInput.fill(name); + const confirmBtn = this.page.getByTestId('test-delete-item'); + await confirmBtn.waitFor({ state: 'visible' }); + await confirmBtn.click({ force: true }); + await expect(this.page.locator(`a[href='/security/users/${name}/details']`)).not.toBeVisible({ + timeout: 10_000, + }); + return; + } catch { + // Confirm dialog didn't appear; retry whole sequence + } + } + throw new Error(`Failed to delete user ${name} after retries`); + }); + } + + async deleteUserFromDetails(name: string) { + return test.step(`Delete user "${name}" from details`, async () => { + await this.gotoUserDetails(name); + await this.page.getByTestId('user-delete-button').waitFor({ state: 'visible' }); + await this.page.getByTestId('user-delete-button').click(); + await this.page.getByPlaceholder(`Type "${name}" to confirm`).fill(name); + await this.page.getByTestId('test-delete-item').click(); + await this.page.waitForURL('**/security/users**'); + }); + } + + async filterUsers(query: string) { + await this.page.getByPlaceholder('Filter by name (regexp)...').fill(query); + await this.page.waitForURL(/[?&]name=/); + } + + async changePassword(newPassword: string) { + await this.page.getByTestId('create-user-password').fill(newPassword); + // Select a mechanism so Save is enabled + await this.page.getByRole('combobox').click(); + await this.page.getByRole('option', { name: 'SCRAM-SHA-256' }).click(); + await this.page.getByTestId('save-password-button').click(); + } + + // --- Roles --- + + async createRole(name: string) { + return test.step(`Create role "${name}"`, async () => { + await this.gotoRoles(); + await this.page.getByTestId('create-role-button').click(); + await this.page.locator('#role-name').waitFor({ state: 'visible' }); + await this.page.locator('#role-name').fill(name); + await this.page.getByTestId('create-role-submit').click(); + await this.page.waitForURL('**/security/roles/**/details'); + }); + } + + async deleteRoleFromList(name: string) { + return test.step(`Delete role "${name}"`, async () => { + for (let attempt = 0; attempt < 4; attempt++) { + await this.gotoRoles(); + await this.page.getByPlaceholder('Filter by name (regexp)...').fill(name); + await this.page.getByTestId(`role-list-item-${name}`).waitFor({ state: 'visible' }); + await this.clickDropdownMenuItem(`role-actions-button-${name}`, `delete-role-button-${name}`); + try { + const confirmInput = this.page.getByPlaceholder(name); + await confirmInput.waitFor({ state: 'visible', timeout: 5000 }); + await confirmInput.fill(name); + const confirmBtn = this.page.getByTestId('confirm-role-delete-button'); + await confirmBtn.waitFor({ state: 'visible' }); + await confirmBtn.click({ force: true }); + await expect(this.page.getByTestId(`role-list-item-${name}`)).not.toBeVisible({ timeout: 10_000 }); + return; + } catch { + // Confirm dialog didn't appear; retry whole sequence + } + } + throw new Error(`Failed to delete role ${name} after retries`); + }); + } + + async filterRoles(query: string) { + await this.page.getByPlaceholder('Filter by name (regexp)...').fill(query); + await this.page.waitForURL(/[?&]name=/); + } + + async addPrincipalToRole(principalName: string) { + const combobox = this.page.getByTestId('add-principal-combobox'); + await combobox.click(); + await combobox.fill(principalName); + await this.page.getByRole('option', { name: principalName }).click(); + } + + async removePrincipalFromRole(principalName: string) { + await this.page.getByTestId(`remove-user-${principalName}-button`).click(); + } + + // --- Users: advanced --- + + async createUserWithRole(name: string, roleName: string) { + return test.step(`Create user "${name}" with role "${roleName}"`, async () => { + await this.gotoUsers(); + await this.page.getByTestId('create-user-button').click(); + await this.page.getByTestId('create-user-name').waitFor({ state: 'visible' }); + await this.page.getByLabel('Username').fill(name); + + // Open the role multi-select and pick the role + await this.page.getByText('Select roles...').click(); + await this.page.getByPlaceholder('Search...').fill(roleName); + await this.page.getByRole('option', { name: roleName }).click(); + // Close the popover by pressing Escape + await this.page.keyboard.press('Escape'); + + await this.page.getByTestId('create-user-submit').click(); + await this.page.getByTestId('done-button').waitFor({ state: 'visible' }); + }); + } + + // Clicks "Allow all operations" on the current user details page and confirms. + async allowAllOperations() { + return test.step('Allow all operations', async () => { + await this.page.getByTestId('allow-all-operations-button').click(); + await this.page.getByRole('dialog', { name: 'Allow all operations' }).waitFor({ state: 'visible' }); + await this.page.getByTestId('confirm-allow-all-button').click(); + await this.page.getByRole('dialog').waitFor({ state: 'hidden' }); + }); + } + + // Assigns a role to the user from the user details page. + async assignRoleFromDetails(roleName: string) { + return test.step(`Assign role "${roleName}" from user details`, async () => { + const combobox = this.page.getByTestId('assign-role-combobox'); + await combobox.click(); + await combobox.fill(roleName); + await this.page.getByRole('option', { name: roleName }).click(); + // Wait for the role row to appear + await this.page.getByTestId(`role-name-${roleName}`).waitFor({ state: 'visible' }); + }); + } + + // Removes a role from the user on the user details page. + async removeRoleFromDetails(roleName: string) { + return test.step(`Remove role "${roleName}" from user details`, async () => { + await this.page.getByTestId(`remove-role-${roleName}`).click(); + await this.page.getByTestId('confirm-remove-role-button').waitFor({ state: 'visible' }); + await this.page.getByTestId('confirm-remove-role-button').click(); + await this.page.getByTestId(`role-name-${roleName}`).waitFor({ state: 'hidden' }); + }); + } +} diff --git a/frontend/tests/test-variant-kafka/fixtures.ts b/frontend/tests/test-variant-kafka/fixtures.ts new file mode 100644 index 0000000000..c323ae866d --- /dev/null +++ b/frontend/tests/test-variant-kafka/fixtures.ts @@ -0,0 +1,19 @@ +import { test as base } from '@playwright/test'; + +type CustomFixtures = { + featureFlags: Record; +}; + +export const test = base.extend({ + featureFlags: [{}, { option: true }], + page: async ({ page, featureFlags }, use) => { + if (Object.keys(featureFlags).length > 0) { + await page.addInitScript((flags: Record) => { + (window as Window & { __E2E_FEATURE_FLAGS__?: Record }).__E2E_FEATURE_FLAGS__ = flags; + }, featureFlags); + } + await use(page); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/frontend/tests/test-variant-kafka/security/security-kafka.spec.ts b/frontend/tests/test-variant-kafka/security/security-kafka.spec.ts new file mode 100644 index 0000000000..f40eb5a3d5 --- /dev/null +++ b/frontend/tests/test-variant-kafka/security/security-kafka.spec.ts @@ -0,0 +1,116 @@ +/** biome-ignore-all lint/performance/useTopLevelRegex: this is a test */ +import { expect, test } from '../fixtures'; + +// ── Old security layout (no feature flag: Kafka default) ────────────────────── + +test.describe('Security (Kafka) › old layout', () => { + test('navigates to ACLs page by default', async ({ page }) => { + await page.goto('/security', { waitUntil: 'domcontentloaded' }); + await page.waitForURL('**/security/acls**'); + }); + + test('Roles tab is disabled', async ({ page }) => { + await page.goto('/security/acls', { waitUntil: 'domcontentloaded' }); + const rolesTab = page.getByRole('tab', { name: 'Roles' }); + await rolesTab.waitFor({ state: 'visible' }); + await expect(rolesTab).toBeDisabled(); + }); + + test('Users tab is disabled', async ({ page }) => { + await page.goto('/security/acls', { waitUntil: 'domcontentloaded' }); + const usersTab = page.getByRole('tab', { name: 'Users' }); + await usersTab.waitFor({ state: 'visible' }); + await expect(usersTab).toBeDisabled(); + }); + + test('ACLs tab is enabled', async ({ page }) => { + await page.goto('/security/acls', { waitUntil: 'domcontentloaded' }); + const aclsTab = page.getByRole('tab', { name: 'ACLs' }); + await aclsTab.waitFor({ state: 'visible' }); + await expect(aclsTab).not.toBeDisabled(); + }); + + test('clicking disabled Roles tab does not navigate', async ({ page }) => { + await page.goto('/security/acls', { waitUntil: 'domcontentloaded' }); + await page.getByRole('tab', { name: 'Roles' }).waitFor({ state: 'visible' }); + await page.getByRole('tab', { name: 'Roles' }).click({ force: true }); + await expect(page).toHaveURL(/\/security\/acls/); + }); +}); + +// ── New security layout (enableNewSecurityPage: true) ───────────────────────── + +test.describe('Security (Kafka) › new layout', () => { + test.use({ featureFlags: { enableNewSecurityPage: true } }); + + test('navigates to users page by default', async ({ page }) => { + await page.goto('/security', { waitUntil: 'domcontentloaded' }); + await page.waitForURL('**/security/users**'); + }); + + test('Roles tab is disabled with tooltip', async ({ page }) => { + await page.goto('/security/users', { waitUntil: 'domcontentloaded' }); + const rolesTab = page.getByRole('tab', { name: 'Roles' }); + await rolesTab.waitFor({ state: 'visible' }); + await expect(rolesTab).toBeDisabled(); + + // Tooltip shows why Roles are unavailable + await rolesTab.hover(); + await expect(page.getByText('Roles are not supported by your cluster.')).toBeVisible({ timeout: 5000 }); + }); + + test('clicking disabled Roles tab does not navigate', async ({ page }) => { + await page.goto('/security/users', { waitUntil: 'domcontentloaded' }); + await page.getByRole('tab', { name: 'Roles' }).waitFor({ state: 'visible' }); + await page.getByRole('tab', { name: 'Roles' }).click({ force: true }); + // Still on users page + await expect(page).toHaveURL(/\/security\/users/); + }); + + test('Users tab is disabled — no SCRAM user management in Kafka', async ({ page }) => { + await page.goto('/security/users', { waitUntil: 'domcontentloaded' }); + const usersTab = page.getByRole('tab', { name: 'Users' }); + await usersTab.waitFor({ state: 'visible' }); + await expect(usersTab).toBeDisabled(); + }); + + test('no Create user button on users page', async ({ page }) => { + await page.goto('/security/users', { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle'); + await expect(page.getByTestId('create-user-button')).not.toBeVisible(); + }); + + test('Roles tab is disabled on Permissions page', async ({ page }) => { + await page.goto('/security/permissions', { waitUntil: 'domcontentloaded' }); + const rolesTab = page.getByRole('tab', { name: 'Roles' }); + await rolesTab.waitFor({ state: 'visible' }); + await expect(rolesTab).toBeDisabled(); + }); + + test('Create ACL button is available on Permissions page', async ({ page }) => { + await page.goto('/security/permissions', { waitUntil: 'domcontentloaded' }); + const createBtn = page.getByRole('button', { name: 'Create ACL' }); + await createBtn.waitFor({ state: 'visible' }); + await expect(createBtn).not.toBeDisabled(); + }); + + test('can open Add ACL dialog and set a user principal', async ({ page }) => { + await page.goto('/security/permissions', { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Create ACL' }).click(); + + // Dialog opens + await page.getByRole('dialog', { name: 'Add ACL' }).waitFor({ state: 'visible' }); + + // User principal input is shown (creatable combobox) + const principalInput = page.getByPlaceholder('Select or type a user...'); + await principalInput.waitFor({ state: 'visible' }); + await principalInput.click(); + await principalInput.fill('e2e-kafka-principal'); + // Confirm option appears (creatable) + await page.getByText('Create "e2e-kafka-principal"').waitFor({ state: 'visible' }); + + // Close without submitting + await page.keyboard.press('Escape'); + await page.getByRole('dialog').waitFor({ state: 'hidden' }); + }); +});