diff --git a/tests/ui-testing/pages/iamPages/iamFormValidationPage.js b/tests/ui-testing/pages/iamPages/iamFormValidationPage.js index 846007bf08..f5cb000292 100644 --- a/tests/ui-testing/pages/iamPages/iamFormValidationPage.js +++ b/tests/ui-testing/pages/iamPages/iamFormValidationPage.js @@ -84,6 +84,24 @@ export class IamFormValidationPage { this.updateRoleSaveBtn = '[data-test="add-user-dialog"] [data-test="o-dialog-primary-btn"]'; this.updateRoleCancelBtn = '[data-test="add-user-dialog"] [data-test="o-dialog-secondary-btn"]'; + // ── Add Role "Start from" preset (new) ──────────────────────────────── + this.roleStartFromCustom = '[data-test="add-role-start-from-custom-radio"]'; + this.roleStartFromReadonly = '[data-test="add-role-start-from-readonly-radio"]'; + + // ── Edit Role / Edit Group pages (auto-route + dirty state, new) ─────── + this.editRolePage = '[data-test="edit-role-page"]'; + this.editRolePermsSection = '[data-test="edit-role-permissions-section"]'; + this.editRolePermsTab = '[data-test="tab-permissions"]'; + this.editRolePermsDirtyDot = '[data-test="tab-permissions-dirty-dot"]'; + this.editGroupSection = '[data-test="edit-group-section"]'; + this.editGroupRolesTab = '[data-test="tab-roles"]'; + this.editGroupRolesDirtyDot= '[data-test="tab-roles-dirty-dot"]'; + + // ── Confirm dialog (shared) — delete blast-radius warning banner (new) ─ + this.confirmDialog = '[data-test="confirm-dialog"]'; + this.confirmDialogOkBtn = '[data-test="confirm-dialog"] [data-test="o-dialog-primary-btn"]'; + this.confirmDialogCancelBtn= '[data-test="confirm-dialog"] [data-test="o-dialog-secondary-btn"]'; + // ── Toast / success messages ────────────────────────────────────────── this.toastSuccess = '[data-test-variant="success"]'; this.toastMessage = '[data-test="o-toast-message"]'; @@ -206,6 +224,39 @@ export class IamFormValidationPage { await this.page.locator(this.roleCancelBtn).click(); } + // Select the "Read-only" start-from preset before submitting the role form. + async selectRoleStartFromReadonly() { + await this.page.locator(this.roleStartFromReadonly).click(); + } + + // Delete the first role/group in the list via its row trash icon, keyed by name. + async clickDeleteRole(roleName) { + await this.page + .locator(`[data-test="iam-roles-delete-${roleName}-role-icon"]`) + .click(); + } + + async clickDeleteGroup(groupName) { + await this.page + .locator(`[data-test="iam-groups-delete-${groupName}-role-icon"]`) + .click(); + } + + // Toggle the first available permission checkbox on the Edit Role Permissions + // tab (used to dirty the tab). Returns true if a checkbox was found+clicked. + async toggleFirstPermissionCheckbox() { + const checkbox = this.page + .locator('[data-test*="-permissions-table-body-row-"][data-test$="-checkbox"]') + .first(); + try { + await checkbox.waitFor({ state: 'visible', timeout: 15000 }); + await checkbox.click(); + return true; + } catch { + return false; + } + } + // ── Service Account form actions ────────────────────────────────────────── async openServiceAccountForm() { @@ -263,6 +314,17 @@ export class IamFormValidationPage { getRoleDialogLocator() { return this.page.locator(this.roleDialog); } getSaDialogLocator() { return this.page.locator(this.saDialog); } getAddUserDialogLocator() { return this.page.locator(this.addUserDialog); } + + // New-behavior getters (start-from, auto-route, delete banner, dirty dots) + getRoleStartFromCustomLocator() { return this.page.locator(this.roleStartFromCustom); } + getRoleStartFromReadonlyLocator() { return this.page.locator(this.roleStartFromReadonly); } + getEditRolePageLocator() { return this.page.locator(this.editRolePage); } + getEditRolePermsSectionLocator() { return this.page.locator(this.editRolePermsSection); } + getEditRolePermsDirtyDotLocator() { return this.page.locator(this.editRolePermsDirtyDot); } + getEditGroupSectionLocator() { return this.page.locator(this.editGroupSection); } + getConfirmDialogLocator() { return this.page.locator(this.confirmDialog); } + getConfirmDialogOkBtnLocator() { return this.page.locator(this.confirmDialogOkBtn); } + getConfirmDialogCancelBtnLocator(){ return this.page.locator(this.confirmDialogCancelBtn); } getUserEmailErrorLocator() { return this.page.locator(this.userEmailError); } getUserPasswordErrorLocator() { return this.page.locator(this.userPasswordError); } getUserRoleErrorLocator() { return this.page.locator(this.userRoleError); } diff --git a/tests/ui-testing/pages/iamPages/ingestionTokensPage.js b/tests/ui-testing/pages/iamPages/ingestionTokensPage.js index dcd19eebdd..ec062d1986 100644 --- a/tests/ui-testing/pages/iamPages/ingestionTokensPage.js +++ b/tests/ui-testing/pages/iamPages/ingestionTokensPage.js @@ -48,6 +48,8 @@ export class IngestionTokensPage { // ============================================================ // Revealed token dialog // ============================================================ + // The block now shows the ready-to-use "Basic base64(name:token)" + // credential (not the raw token). this.revealedTokenCode = page.locator('[role="dialog"] code'); } @@ -95,8 +97,16 @@ export class IngestionTokensPage { await this.page.waitForTimeout(500); } + // Copies the ready-to-use "Basic base64(name:token)" credential (primary btn). async clickCopyToken() { - await this.page.getByRole('button', { name: 'Copy' }).last().click(); + await this.page + .getByRole('button', { name: 'Copy Authorization header' }) + .click(); + } + + // Copies the raw o2oi_ token (secondary btn). + async clickCopyRawToken() { + await this.page.getByRole('button', { name: 'Copy raw token' }).click(); } // ----- toggle ----- diff --git a/tests/ui-testing/playwright-tests/GeneralTests/iam-form-validation.spec.js b/tests/ui-testing/playwright-tests/GeneralTests/iam-form-validation.spec.js index f30e61ced5..bf594c68aa 100644 --- a/tests/ui-testing/playwright-tests/GeneralTests/iam-form-validation.spec.js +++ b/tests/ui-testing/playwright-tests/GeneralTests/iam-form-validation.spec.js @@ -158,7 +158,7 @@ test.describe("IAM Group form validation", { tag: '@enterprise' }, () => { await pm.iamFormValidation.fillGroupName('group with spaces'); await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toBeVisible(); await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toContainText( - "Use alphanumeric and '_' characters only, without spaces." + "Use letters, numbers and underscores only, without spaces." ); await expect(pm.iamFormValidation.getGroupSubmitBtnLocator()).toBeDisabled(); @@ -173,7 +173,7 @@ test.describe("IAM Group form validation", { tag: '@enterprise' }, () => { await pm.iamFormValidation.openGroupForm(); await pm.iamFormValidation.fillGroupName('group@name!'); await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toBeVisible(); - await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toContainText("Use alphanumeric and '_' characters only, without spaces."); + await expect(pm.iamFormValidation.getGroupNameErrorLocator()).toContainText("Use letters, numbers and underscores only, without spaces."); await expect(pm.iamFormValidation.getGroupSubmitBtnLocator()).toBeDisabled(); testLogger.info('Group special-char error correctly shown'); @@ -194,6 +194,46 @@ test.describe("IAM Group form validation", { tag: '@enterprise' }, () => { testLogger.info('Group created successfully'); }); + + test("should auto-route to Edit Group after create", { + tag: ['@iamFormValidation', '@functional', '@P1'] + }, async ({ page }) => { + const groupName = `e2e_iam_group_${Date.now()}`; + testLogger.info(`Creating group and expecting auto-route: ${groupName}`); + + await pm.iamFormValidation.openGroupForm(); + await pm.iamFormValidation.fillGroupName(groupName); + await pm.iamFormValidation.submitGroupForm(); + + // Lands on the Edit Group page instead of returning to the list. + await expect(pm.iamFormValidation.getEditGroupSectionLocator()).toBeVisible({ timeout: 15000 }); + await expect(page).toHaveURL(/groups\/edit\//); + + testLogger.info('Auto-routed to Edit Group'); + }); + + test("should warn about access loss in the group delete confirm dialog", { + tag: ['@iamFormValidation', '@functional', '@P1'] + }, async ({ page }) => { + const groupName = `e2e_iam_group_${Date.now()}`; + testLogger.info(`Creating then deleting group to check blast-radius: ${groupName}`); + + await pm.iamFormValidation.openGroupForm(); + await pm.iamFormValidation.fillGroupName(groupName); + await pm.iamFormValidation.submitGroupForm(); + await expect(pm.iamFormValidation.getEditGroupSectionLocator()).toBeVisible({ timeout: 15000 }); + + // Back to the groups list, then delete. + await pm.iamFormValidation.navigateToGroupsTab(); + await pm.iamFormValidation.clickDeleteGroup(groupName); + + await expect(pm.iamFormValidation.getConfirmDialogLocator()).toBeVisible(); + await expect(pm.iamFormValidation.getConfirmDialogLocator()).toContainText(/lose the roles/i); + + await pm.iamFormValidation.getConfirmDialogOkBtnLocator().click(); + + testLogger.info('Group delete blast-radius warning shown'); + }); }); // ── Role form (@enterprise — Roles tab requires isEnterprise && rbac_enabled) ── @@ -239,7 +279,7 @@ test.describe("IAM Role form validation", { tag: '@enterprise' }, () => { await pm.iamFormValidation.fillRoleName('role with spaces'); await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toBeVisible(); await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toContainText( - "Use alphanumeric and '_' characters only, without spaces." + "Use letters, numbers and underscores only, without spaces." ); await expect(pm.iamFormValidation.getRoleSubmitBtnLocator()).toBeDisabled(); @@ -254,7 +294,7 @@ test.describe("IAM Role form validation", { tag: '@enterprise' }, () => { await pm.iamFormValidation.openRoleForm(); await pm.iamFormValidation.fillRoleName('bad role name!'); await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toBeVisible(); - await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toContainText("Use alphanumeric and '_' characters only, without spaces."); + await expect(pm.iamFormValidation.getRoleNameErrorLocator()).toContainText("Use letters, numbers and underscores only, without spaces."); await pm.iamFormValidation.fillRoleName('valid_role_name'); await expect(pm.iamFormValidation.getRoleNameErrorLocator()).not.toBeVisible(); @@ -278,6 +318,94 @@ test.describe("IAM Role form validation", { tag: '@enterprise' }, () => { testLogger.info('Role created successfully'); }); + + test("should show 'Start from' preset options in the Add Role dialog", { + tag: ['@iamFormValidation', '@functional', '@P1'] + }, async ({ page }) => { + testLogger.info('Testing Start-from preset options render'); + + await pm.iamFormValidation.openRoleForm(); + await expect(pm.iamFormValidation.getRoleStartFromCustomLocator()).toBeVisible(); + await expect(pm.iamFormValidation.getRoleStartFromReadonlyLocator()).toBeVisible(); + + testLogger.info('Start-from Custom + Read-only options rendered'); + }); + + test("should auto-route to Edit Role Permissions tab after create", { + tag: ['@iamFormValidation', '@functional', '@P1'] + }, async ({ page }) => { + const roleName = `e2e_iam_role_${Date.now()}`; + testLogger.info(`Creating role and expecting auto-route: ${roleName}`); + + await pm.iamFormValidation.openRoleForm(); + await pm.iamFormValidation.fillRoleName(roleName); + await pm.iamFormValidation.submitRoleForm(); + + // Instead of returning to the list, we land on the Edit Role page with the + // Permissions tab active. + await expect(pm.iamFormValidation.getEditRolePageLocator()).toBeVisible({ timeout: 15000 }); + await expect(pm.iamFormValidation.getEditRolePermsSectionLocator()).toBeVisible(); + await expect(page).toHaveURL(/roles\/edit\//); + + testLogger.info('Auto-routed to Edit Role Permissions tab'); + }); + + test("should warn about access loss in the delete confirm dialog", { + tag: ['@iamFormValidation', '@functional', '@P1'] + }, async ({ page }) => { + const roleName = `e2e_iam_role_${Date.now()}`; + testLogger.info(`Creating then deleting role to check blast-radius: ${roleName}`); + + // Create a role, then return to the list to delete it. + await pm.iamFormValidation.openRoleForm(); + await pm.iamFormValidation.fillRoleName(roleName); + await pm.iamFormValidation.submitRoleForm(); + await expect(pm.iamFormValidation.getEditRolePageLocator()).toBeVisible({ timeout: 15000 }); + + // Back to the roles list. + await pm.iamFormValidation.navigateToRolesTab(); + await pm.iamFormValidation.clickDeleteRole(roleName); + + // Confirm dialog shows a blast-radius warning banner mentioning access loss. + await expect(pm.iamFormValidation.getConfirmDialogLocator()).toBeVisible(); + await expect(pm.iamFormValidation.getConfirmDialogLocator()).toContainText(/lose access/i); + + // Confirm the delete to clean up. + await pm.iamFormValidation.getConfirmDialogOkBtnLocator().click(); + + testLogger.info('Delete confirm blast-radius warning shown'); + }); + + test("should mark the Permissions tab dirty and prompt on leave with unsaved changes", { + tag: ['@iamFormValidation', '@functional', '@P2'] + }, async ({ page }) => { + const roleName = `e2e_iam_role_${Date.now()}`; + testLogger.info(`Testing dirty dot + discard guard: ${roleName}`); + + await pm.iamFormValidation.openRoleForm(); + await pm.iamFormValidation.fillRoleName(roleName); + await pm.iamFormValidation.submitRoleForm(); + await expect(pm.iamFormValidation.getEditRolePageLocator()).toBeVisible({ timeout: 15000 }); + + // Toggle a permission to dirty the Permissions tab. + const toggled = await pm.iamFormValidation.toggleFirstPermissionCheckbox(); + if (!toggled) { + test.skip(true, 'No permission checkboxes rendered for this org — cannot exercise dirty state'); + return; + } + + await expect(pm.iamFormValidation.getEditRolePermsDirtyDotLocator()).toBeVisible(); + + // Attempt to leave — a discard-changes confirm dialog should appear. + await pm.iamFormValidation.navigateToRolesTab(); + await expect(pm.iamFormValidation.getConfirmDialogLocator()).toBeVisible(); + await expect(pm.iamFormValidation.getConfirmDialogLocator()).toContainText(/discard/i); + + // Discard and continue. + await pm.iamFormValidation.getConfirmDialogOkBtnLocator().click(); + + testLogger.info('Dirty dot + discard-changes guard verified'); + }); }); // ── Service Account email format validation ─────────────────────────────────── diff --git a/tests/ui-testing/playwright-tests/GeneralTests/ingestionTokens.spec.js b/tests/ui-testing/playwright-tests/GeneralTests/ingestionTokens.spec.js index 9da5695386..5db0aa4614 100644 --- a/tests/ui-testing/playwright-tests/GeneralTests/ingestionTokens.spec.js +++ b/tests/ui-testing/playwright-tests/GeneralTests/ingestionTokens.spec.js @@ -67,12 +67,13 @@ test.describe("Org-Level Ingestion Tokens", () => { // Should see success toast await pageManager.ingestionTokensPage.verifySuccessMessage('Token created successfully.'); - // Revealed dialog should show the token + // Revealed dialog shows the ready-to-use "Basic base64(name:token)" + // credential (not the raw token) so it can be pasted straight into an + // Authorization header. const revealedCode = pageManager.ingestionTokensPage.revealedTokenCode; await expect(revealedCode).toBeVisible({ timeout: 5000 }); - const tokenText = await revealedCode.textContent(); - expect(tokenText).toMatch(/^o2oi_/); - expect(tokenText.length).toBe(37); + const credText = await revealedCode.textContent(); + expect(credText).toMatch(/^Basic /); // Close revealed dialog await pageManager.ingestionTokensPage.closeRevealedDialog(); @@ -135,19 +136,23 @@ test.describe("Org-Level Ingestion Tokens", () => { await pageManager.ingestionTokensPage.fillTokenName(uniqueName); await pageManager.ingestionTokensPage.clickCreate(); - // Wait for revealed dialog + // Wait for revealed dialog — shows the ready-to-use Basic credential. const revealedCode = pageManager.ingestionTokensPage.revealedTokenCode; await expect(revealedCode).toBeVisible({ timeout: 5000 }); - const tokenText = await revealedCode.textContent(); - expect(tokenText).toMatch(/^o2oi_/); + const credText = await revealedCode.textContent(); + expect(credText).toMatch(/^Basic /); - // Grant clipboard permissions and copy await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); - await pageManager.ingestionTokensPage.clickCopyToken(); - // Verify clipboard contains the token - const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); - expect(clipboardText).toBe(tokenText); + // Primary "Copy Authorization header" copies the Basic credential shown. + await pageManager.ingestionTokensPage.clickCopyToken(); + const authClipboard = await page.evaluate(() => navigator.clipboard.readText()); + expect(authClipboard).toBe(credText); + + // Secondary "Copy raw token" copies the raw o2oi_ token. + await pageManager.ingestionTokensPage.clickCopyRawToken(); + const rawClipboard = await page.evaluate(() => navigator.clipboard.readText()); + expect(rawClipboard).toMatch(/^o2oi_/); // Close dialog await pageManager.ingestionTokensPage.closeRevealedDialog(); diff --git a/tests/ui-testing/playwright-tests/GeneralTests/serviceAccount.spec.js b/tests/ui-testing/playwright-tests/GeneralTests/serviceAccount.spec.js index 4dde30bd2b..b11f0eb9a6 100644 --- a/tests/ui-testing/playwright-tests/GeneralTests/serviceAccount.spec.js +++ b/tests/ui-testing/playwright-tests/GeneralTests/serviceAccount.spec.js @@ -214,6 +214,54 @@ test.describe("Service Account for API access", () => { testLogger.info('Test completed successfully'); }); + test("Token wizard Grant permissions step shows role/group actions", async ({ page }, testInfo) => { + const uniqueEmail = `email${Date.now()}_${Math.floor(Math.random() * 10000)}@gmail.com`; + testLogger.testStart(testInfo.title, testInfo.file); + + await navigateToBase(page); + pageManager = new PageManager(page); + + await pageManager.iamPage.gotoIamPage(); + await pageManager.iamPage.iamPageServiceAccountsTab(); + await waitForServiceAccountsPage(page); + await pageManager.iamPage.iamPageAddServiceAccount(); + await pageManager.iamPage.enterEmailServiceAccount(uniqueEmail); + await pageManager.iamPage.clickSaveServiceAccount(); + await pageManager.iamPage.verifySuccessMessage('Service Account created successfully.'); + + // Advance the token wizard to step 2 (Grant permissions). + const nextBtn = page.locator('[data-test="service-accounts-token-next-btn"]'); + await expect(nextBtn).toBeVisible({ timeout: 10000 }); + await nextBtn.click(); + + // The two grant actions only render on Enterprise/Cloud (showGroupLink = + // isEnterprise || isCloud). On an OSS build step 2 shows a plain hint with + // no links, so gate-skip the label assertions there instead of failing. + const roleLink = page.locator('[data-test="service-accounts-list-token-add-to-role"]'); + const groupLink = page.locator('[data-test="service-accounts-list-token-add-to-group"]'); + const grantLinksRendered = await roleLink + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false); + + if (!grantLinksRendered) { + testLogger.info('Grant links absent (OSS build) — skipping label assertions'); + await page.locator('[data-test="service-accounts-token-done-btn"]').click(); + test.skip(true, 'Token wizard grant links are Enterprise/Cloud-only (showGroupLink=false on OSS)'); + return; + } + + // Step 2 offers the two grant actions with the reworded labels. + await expect(groupLink).toBeVisible(); + await expect(roleLink).toContainText('Assign a role'); + await expect(groupLink).toContainText('Add to a user group'); + + // Close the wizard. + await page.locator('[data-test="service-accounts-token-done-btn"]').click(); + + testLogger.info('Grant permissions step verified'); + }); + test("SRE Agent System Account Protection", async ({ page }, testInfo) => { testLogger.testStart(testInfo.title, testInfo.file); diff --git a/web/src/components/common/AppTabs.spec.ts b/web/src/components/common/AppTabs.spec.ts index 1e1e3523f0..7cdac38b10 100644 --- a/web/src/components/common/AppTabs.spec.ts +++ b/web/src/components/common/AppTabs.spec.ts @@ -128,4 +128,56 @@ describe("AppTabs", () => { // Hidden tab is not in DOM expect(wrapper.find('[data-test="tab-tab4"]').exists()).toBe(false); }); + + describe("dirty dot", () => { + it("renders a dirty dot when tab.dirty is true", () => { + const dirtyWrapper = mount(AppTabs, { + props: { + tabs: [ + { label: "Clean", value: "clean" }, + { label: "Dirty", value: "dirty", dirty: true }, + ], + activeTab: "clean", + }, + }); + + expect( + dirtyWrapper.find('[data-test="tab-dirty-dirty-dot"]').exists(), + ).toBe(true); + expect( + dirtyWrapper.find('[data-test="tab-clean-dirty-dot"]').exists(), + ).toBe(false); + dirtyWrapper.unmount(); + }); + + it("renders no dot when dirty is false or undefined (existing callers unaffected)", () => { + // mockTabs has no `dirty` prop on any tab — none should render a dot. + const dots = wrapper.findAll('[data-test$="-dirty-dot"]'); + expect(dots).toHaveLength(0); + + const falseWrapper = mount(AppTabs, { + props: { + tabs: [{ label: "Tab", value: "tab", dirty: false }], + activeTab: "tab", + }, + }); + expect(falseWrapper.find('[data-test="tab-tab-dirty-dot"]').exists()).toBe( + false, + ); + falseWrapper.unmount(); + }); + + it("applies dirtyTitle to the dot when provided", () => { + const titledWrapper = mount(AppTabs, { + props: { + tabs: [{ label: "Dirty", value: "dirty", dirty: true }], + activeTab: "dirty", + dirtyTitle: "Unsaved changes", + }, + }); + const dot = titledWrapper.find('[data-test="tab-dirty-dirty-dot"]'); + expect(dot.attributes("title")).toBe("Unsaved changes"); + titledWrapper.unmount(); + }); + }); }); diff --git a/web/src/components/common/AppTabs.vue b/web/src/components/common/AppTabs.vue index 16c9013b14..ec3bf7905b 100644 --- a/web/src/components/common/AppTabs.vue +++ b/web/src/components/common/AppTabs.vue @@ -35,6 +35,13 @@ along with this program. If not, see . {{ tab.label }} +