From 2a58781e37c0ecd405defe59bf5d4f2e9dd54682 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:30:54 +0100 Subject: [PATCH] test(ui): update E2E page objects and improve test stability (#10158) --- ui/CHANGELOG.md | 1 + ui/tests/auth/auth-middleware.spec.ts | 26 +- ui/tests/auth/auth-session-errors.spec.ts | 25 +- ui/tests/invitations/invitations-page.ts | 49 ++-- ui/tests/providers/providers-page.ts | 311 ++++++++++++++-------- ui/tsconfig.json | 5 +- 6 files changed, 242 insertions(+), 175 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8c7811d81b..738dbc5bd6 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to the **Prowler UI** are documented in this file. - CSA CCM detailed view and small fix related with `Top Failed Sections` width [(#10018)](https://github.com/prowler-cloud/prowler/pull/10018) - Attack Paths: Show scan data availability status with badges and tooltips, allow selecting scans for querying while a new scan is in progress [(#10089)](https://github.com/prowler-cloud/prowler/pull/10089) - Attack Paths: Catches not found and permissions (for read only queries) errors [(#10140)](https://github.com/prowler-cloud/prowler/pull/10140) +- Provider connection flow was unified into a modal wizard with AWS Organizations bulk onboarding, safer secret retry handling, and more stable E2E coverage [(#10153)](https://github.com/prowler-cloud/prowler/pull/10153) [(#10154)](https://github.com/prowler-cloud/prowler/pull/10154) [(#10155)](https://github.com/prowler-cloud/prowler/pull/10155) [(#10156)](https://github.com/prowler-cloud/prowler/pull/10156) [(#10157)](https://github.com/prowler-cloud/prowler/pull/10157) [(#10158)](https://github.com/prowler-cloud/prowler/pull/10158) ### 🔐 Security diff --git a/ui/tests/auth/auth-middleware.spec.ts b/ui/tests/auth/auth-middleware.spec.ts index 16ef42997a..d8e89640c9 100644 --- a/ui/tests/auth/auth-middleware.spec.ts +++ b/ui/tests/auth/auth-middleware.spec.ts @@ -1,6 +1,6 @@ -import { test } from "@playwright/test"; +import { expect, test } from "@playwright/test"; -import { TEST_CREDENTIALS } from "../helpers"; +import { getSessionWithoutCookies, TEST_CREDENTIALS } from "../helpers"; import { ProvidersPage } from "../providers/providers-page"; import { ScansPage } from "../scans/scans-page"; import { SignInPage } from "../sign-in-base/sign-in-base-page"; @@ -40,24 +40,14 @@ test.describe("Middleware Error Handling", () => { await providersPage.goto(); await providersPage.verifyPageLoaded(); - const cookies = await context.cookies(); - const sessionCookie = cookies.find((c) => - c.name.includes("authjs.session-token"), - ); + // Remove auth cookies to simulate a broken/expired session deterministically. + await context.clearCookies(); - if (sessionCookie) { - await context.clearCookies(); - await context.addCookies([ - { - ...sessionCookie, - value: "invalid-session-token", - }, - ]); + const expiredSession = await getSessionWithoutCookies(page); + expect(expiredSession).toBeNull(); - await scansPage.goto(); - // With invalid session, should redirect to sign-in - await signInPage.verifyOnSignInPage(); - } + await scansPage.goto(); + await signInPage.verifyOnSignInPage(); }, ); diff --git a/ui/tests/auth/auth-session-errors.spec.ts b/ui/tests/auth/auth-session-errors.spec.ts index 55521f22b3..000ade831d 100644 --- a/ui/tests/auth/auth-session-errors.spec.ts +++ b/ui/tests/auth/auth-session-errors.spec.ts @@ -1,8 +1,5 @@ import { expect, test } from "@playwright/test"; -import { TEST_CREDENTIALS } from "../helpers"; -import { ProvidersPage } from "../providers/providers-page"; -import { ScansPage } from "../scans/scans-page"; import { SignInPage } from "../sign-in-base/sign-in-base-page"; test.describe("Session Error Messages", () => { @@ -65,28 +62,10 @@ test.describe("Session Error Messages", () => { { tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-004"] }, async ({ page, context }) => { const signInPage = new SignInPage(page); - const scansPage = new ScansPage(page); - const providersPage = new ProvidersPage(page); - - await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID); - - // Navigate to a specific page (just need to be on a protected route) - await scansPage.goto(); - await expect(page.locator("main")).toBeVisible(); - - // Navigate to a safe public page before clearing cookies - // This prevents background requests from the protected page (scans) - // triggering a client-side redirect race condition when cookies are cleared - await signInPage.goto(); - - // Clear cookies to simulate session expiry await context.clearCookies(); - // Try to navigate to a different protected route - // Use fresh navigation to force middleware evaluation - await providersPage.gotoFresh(); - - // Should be redirected to login with callbackUrl + // Navigate directly to a protected route and assert callbackUrl preservation. + await page.goto("/providers", { waitUntil: "commit" }); await signInPage.verifyRedirectWithCallback("/providers"); }, ); diff --git a/ui/tests/invitations/invitations-page.ts b/ui/tests/invitations/invitations-page.ts index 72d6d4fdc9..6e1e61c550 100644 --- a/ui/tests/invitations/invitations-page.ts +++ b/ui/tests/invitations/invitations-page.ts @@ -1,9 +1,7 @@ import { Page, Locator, expect } from "@playwright/test"; import { BasePage } from "../base-page"; - export class InvitationsPage extends BasePage { - // Page heading readonly pageHeadingSendInvitation: Locator; readonly pageHeadingInvitations: Locator; @@ -18,34 +16,52 @@ export class InvitationsPage extends BasePage { readonly reviewInvitationDetailsButton: Locator; readonly shareUrl: Locator; - constructor(page: Page) { super(page); // Page heading - this.pageHeadingInvitations = page.getByRole("heading", { name: "Invitations" }); - this.pageHeadingSendInvitation = page.getByRole("heading", { name: "Send Invitation" }); + this.pageHeadingInvitations = page.getByRole("heading", { + name: "Invitations", + }); + this.pageHeadingSendInvitation = page.getByRole("heading", { + name: "Send Invitation", + }); // Button to invite a new user - this.inviteButton = page.getByRole("link", { name: "Send Invitation", exact: true }); - this.sendInviteButton = page.getByRole("button", { name: "Send Invitation", exact: true }); + this.inviteButton = page.getByRole("link", { + name: "Send Invitation", + exact: true, + }); + this.sendInviteButton = page.getByRole("button", { + name: "Send Invitation", + exact: true, + }); // Form inputs this.emailInput = page.getByRole("textbox", { name: "Email" }); // Form select - this.roleSelect = page.getByRole("combobox", { name: /Role|Select a role/i }); + this.roleSelect = page + .getByRole("combobox", { name: /Role|Select a role/i }) + .or(page.getByRole("button", { name: /Role|Select a role/i })) + .first(); // Form details - this.reviewInvitationDetailsButton = page.getByRole('button', { name: /Review Invitation Details/i }); + this.reviewInvitationDetailsButton = page.getByRole("button", { + name: /Review Invitation Details/i, + }); // Multiple strategies to find the share URL - this.shareUrl = page.locator('a[href*="/sign-up?invitation_token="], [data-testid="share-url"], .share-url, code, pre').first(); + this.shareUrl = page + .locator( + 'a[href*="/sign-up?invitation_token="], [data-testid="share-url"], .share-url, code, pre', + ) + .first(); } async goto(): Promise { // Navigate to the invitations page - + await super.goto("/invitations"); } @@ -83,18 +99,21 @@ export class InvitationsPage extends BasePage { // Select the role option // Open the role dropdown + await expect(this.roleSelect).toBeVisible({ timeout: 15000 }); await this.roleSelect.click(); // Prefer ARIA role option inside listbox - const option = this.page.getByRole("option", { name: new RegExp(`^${role}$`, "i") }); + const option = this.page.getByRole("option", { + name: new RegExp(`^${role}$`, "i"), + }); if (await option.count()) { await option.first().click(); } else { throw new Error(`Role option ${role} not found`); } - // Ensure the combobox now shows the chosen value - await expect(this.roleSelect).toContainText(new RegExp(role, "i")); + // Ensure a role value was selected in the trigger + await expect(this.roleSelect).not.toContainText(/Select a role/i); } async verifyInviteDataPageLoaded(): Promise { @@ -108,7 +127,7 @@ export class InvitationsPage extends BasePage { // Get the share url text content const text = await this.shareUrl.textContent(); - + if (!text) { throw new Error("Share url not found"); } diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index b96caa8362..b94b8804ce 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -194,6 +194,9 @@ export interface AlibabaCloudProviderCredential { // Providers page export class ProvidersPage extends BasePage { + readonly wizardModal: Locator; + readonly wizardTitle: Locator; + // Alias input readonly aliasInput: Locator; @@ -288,12 +291,31 @@ export class ProvidersPage extends BasePage { constructor(page: Page) { super(page); - // Button to add a new cloud provider - this.addProviderButton = page.getByRole("link", { - name: "Add Cloud Provider", - exact: true, + this.wizardModal = page + .getByRole("dialog") + .filter({ + has: page.getByRole("heading", { + name: /Adding A Cloud Provider|Update Provider Credentials/i, + }), + }) + .first(); + this.wizardTitle = page.getByRole("heading", { + name: /Adding A Cloud Provider|Update Provider Credentials/i, }); + // Button to add a new cloud provider + this.addProviderButton = page + .getByRole("button", { + name: "Add Cloud Provider", + exact: true, + }) + .or( + page.getByRole("link", { + name: "Add Cloud Provider", + exact: true, + }), + ); + // Table displaying existing providers this.providersTable = page.getByRole("table"); @@ -507,6 +529,25 @@ export class ProvidersPage extends BasePage { await this.addProviderButton.click(); } + async openProviderWizardModal(): Promise { + await this.clickAddProvider(); + await this.verifyWizardModalOpen(); + } + + async closeProviderWizardModal(): Promise { + await this.page.keyboard.press("Escape"); + await expect(this.wizardModal).not.toBeVisible(); + } + + async verifyWizardModalOpen(): Promise { + await expect(this.wizardModal).toBeVisible(); + await expect(this.wizardTitle).toBeVisible(); + } + + async advanceWizardStep(): Promise { + await this.clickNext(); + } + private async selectProviderRadio(radio: Locator): Promise { // Force click to handle overlay intercepts await radio.click({ force: true }); @@ -536,8 +577,71 @@ export class ProvidersPage extends BasePage { await this.selectProviderRadio(this.githubProviderRadio); } + async selectAWSSingleAccountMethod(): Promise { + await this.page + .getByRole("button", { + name: "Add A Single AWS Cloud Account", + exact: true, + }) + .click(); + } + + async selectAWSOrganizationsMethod(): Promise { + await this.page + .getByRole("button", { + name: "Add Multiple Accounts With AWS Organizations", + exact: true, + }) + .click(); + } + + async verifyOrganizationsAuthenticationStepLoaded(): Promise { + await this.verifyWizardModalOpen(); + await expect( + this.page.getByRole("heading", { + name: /Authentication Details/i, + }), + ).toBeVisible(); + } + + async verifyOrganizationsAccountSelectionStepLoaded(): Promise { + await this.verifyWizardModalOpen(); + await expect( + this.page.getByText( + /Confirm all accounts under this Organization you want to add to Prowler\./i, + ), + ).toBeVisible(); + } + + async verifyOrganizationsLaunchStepLoaded(): Promise { + await this.verifyWizardModalOpen(); + await expect(this.page.getByText(/Accounts Connected!/i)).toBeVisible(); + } + + async chooseOrganizationsScanSchedule( + option: "daily" | "single", + ): Promise { + const trigger = this.page.getByRole("combobox"); + await trigger.click(); + + const optionName = + option === "single" + ? "Run a single scan (no recurring schedule)" + : "Scan Daily (every 24 hours)"; + + await this.page.getByRole("option", { name: optionName }).click(); + } + async fillAWSProviderDetails(data: AWSProviderData): Promise { // Fill the AWS provider details + const singleAccountButton = this.page.getByRole("button", { + name: "Add A Single AWS Cloud Account", + exact: true, + }); + + if (await singleAccountButton.isVisible().catch(() => false)) { + await singleAccountButton.click(); + } await this.accountIdInput.fill(data.accountId); @@ -599,116 +703,83 @@ export class ProvidersPage extends BasePage { } async clickNext(): Promise { - // The wizard interface may use different labels for its primary action button on each step. - // This function determines which button to click depending on the current URL and page content. + await this.verifyWizardModalOpen(); - // Get the current page URL - const url = this.page.url(); - - // If on the "connect-account" step, click the "Next" button - if (/\/providers\/connect-account/.test(url)) { - await this.nextButton.click(); + const launchScanButton = this.page.getByRole("button", { + name: "Launch scan", + exact: true, + }); + if (await launchScanButton.isVisible().catch(() => false)) { + await launchScanButton.click(); + await this.handleLaunchScanCompletion(); return; } - // If on the "add-credentials" step, check for "Save" and "Next" buttons - if (/\/providers\/add-credentials/.test(url)) { - // Some UI implementations use "Save" instead of "Next" for primary action + const actionNames = [ + "Go to scans", + "Authenticate", + "Next", + "Save", + "Check connection", + ] as const; - if (await this.saveButton.count()) { - await this.saveButton.click(); - return; - } - // If "Save" is not present, try clicking the "Next" button - if (await this.nextButton.count()) { - await this.nextButton.click(); + for (const actionName of actionNames) { + const button = this.page.getByRole("button", { + name: actionName, + exact: true, + }); + if (await button.isVisible().catch(() => false)) { + await button.click(); return; } } - // If on the "test-connection" step, click the "Launch scan" button - if (/\/providers\/test-connection/.test(url)) { - const buttonByText = this.page - .locator("button") - .filter({ hasText: "Launch scan" }); - - await buttonByText.click(); - - // Wait for either success (redirect to scans) or error message to appear - const errorMessage = this.page - .locator( - "div.border-border-error, div.bg-red-100, p.text-text-error-primary, p.text-danger", - ) - .first(); - - // Helper to check and throw error if visible - const checkAndThrowError = async (): Promise => { - const isErrorVisible = await errorMessage - .isVisible() - .catch(() => false); - - if (isErrorVisible) { - const errorText = await errorMessage.textContent(); - - throw new Error( - `Test connection failed with error: ${errorText?.trim() || "Unknown error"}`, - ); - } - }; - - try { - // Wait up to 15 seconds for either the error message or redirect - await Promise.race([ - errorMessage.waitFor({ state: "visible", timeout: 15000 }), - this.page.waitForURL(/\/scans/, { timeout: 15000 }), - ]); - - // If we're still on test-connection page, check for error - if (/\/providers\/test-connection/.test(this.page.url())) { - await checkAndThrowError(); - } - } catch (error) { - await checkAndThrowError(); - throw error; - } - - return; - } - - // Fallback logic: try finding any common primary action buttons in expected order - const candidates: Array<{ name: string | RegExp; exact?: boolean }> = [ - { name: "Next", exact: true }, // Try the "Next" button (exact match to avoid Next.js dev tools) - { name: "Save", exact: true }, // Try the "Save" button - { name: "Launch scan" }, // Try the "Launch scan" button - { name: /Continue|Proceed/i }, // Try "Continue" or "Proceed" (case-insensitive) - ]; - - // Try each candidate name and click it if found - for (const candidate of candidates) { - // Exclude Next.js dev tools button by filtering out buttons with aria-haspopup attribute - const btn = this.page - .getByRole("button", { - name: candidate.name, - exact: candidate.exact, - }) - .and(this.page.locator(":not([aria-haspopup])")); - - if (await btn.count()) { - await btn.click(); - return; - } - } - - // If none of the expected action buttons are present, throw an error throw new Error( - "Could not find an actionable Next/Save/Launch scan button on this step", + "Could not find an actionable primary button in the provider wizard modal.", ); } - async selectCredentialsType(type: AWSCredentialType): Promise { - // Ensure we are on the add-credentials page where the selector exists + private async handleLaunchScanCompletion(): Promise { + const errorMessage = this.page + .locator( + "div.border-border-error, div.bg-red-100, p.text-text-error-primary, p.text-danger", + ) + .first(); + const goToScansButton = this.page.getByRole("button", { + name: "Go to scans", + exact: true, + }); - await expect(this.page).toHaveURL(/\/providers\/add-credentials/); + try { + await Promise.race([ + this.page.waitForURL(/\/scans/, { timeout: 30000 }), + goToScansButton.waitFor({ state: "visible", timeout: 30000 }), + errorMessage.waitFor({ state: "visible", timeout: 30000 }), + ]); + } catch { + // Continue and inspect visible state below. + } + + const isErrorVisible = await errorMessage.isVisible().catch(() => false); + if (isErrorVisible) { + const errorText = await errorMessage.textContent(); + throw new Error( + `Test connection failed with error: ${errorText?.trim() || "Unknown error"}`, + ); + } + + const isGoToScansVisible = await goToScansButton + .isVisible() + .catch(() => false); + if (isGoToScansVisible) { + await goToScansButton.click(); + await this.page.waitForURL(/\/scans/, { timeout: 30000 }); + } + } + + async selectCredentialsType(type: AWSCredentialType): Promise { + await this.verifyWizardModalOpen(); + await expect(this.roleCredentialsRadio).toBeVisible(); if (type === AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN) { await this.roleCredentialsRadio.click({ force: true }); @@ -720,9 +791,8 @@ export class ProvidersPage extends BasePage { } async selectM365CredentialsType(type: M365CredentialType): Promise { - // Ensure we are on the add-credentials page where the selector exists - - await expect(this.page).toHaveURL(/\/providers\/add-credentials/); + await this.verifyWizardModalOpen(); + await expect(this.m365StaticCredentialsRadio).toBeVisible(); if (type === M365_CREDENTIAL_OPTIONS.M365_CREDENTIALS) { await this.m365StaticCredentialsRadio.click({ force: true }); @@ -734,9 +804,8 @@ export class ProvidersPage extends BasePage { } async selectGCPCredentialsType(type: GCPCredentialType): Promise { - // Ensure we are on the add-credentials page where the selector exists - - await expect(this.page).toHaveURL(/\/providers\/add-credentials/); + await this.verifyWizardModalOpen(); + await expect(this.gcpServiceAccountRadio).toBeVisible(); if (type === GCP_CREDENTIAL_OPTIONS.GCP_SERVICE_ACCOUNT) { await this.gcpServiceAccountRadio.click({ force: true }); } else { @@ -745,9 +814,8 @@ export class ProvidersPage extends BasePage { } async selectGitHubCredentialsType(type: GitHubCredentialType): Promise { - // Ensure we are on the add-credentials page where the selector exists - - await expect(this.page).toHaveURL(/\/providers\/add-credentials/); + await this.verifyWizardModalOpen(); + await expect(this.githubPersonalAccessTokenRadio).toBeVisible(); if (type === GITHUB_CREDENTIAL_OPTIONS.GITHUB_PERSONAL_ACCESS_TOKEN) { await this.githubPersonalAccessTokenRadio.click({ force: true }); @@ -960,9 +1028,8 @@ export class ProvidersPage extends BasePage { async selectAlibabaCloudCredentialsType( type: AlibabaCloudCredentialType, ): Promise { - // Ensure we are on the add-credentials page where the selector exists - - await expect(this.page).toHaveURL(/\/providers\/add-credentials/); + await this.verifyWizardModalOpen(); + await expect(this.alibabacloudStaticCredentialsRadio).toBeVisible(); if (type === ALIBABACLOUD_CREDENTIAL_OPTIONS.ALIBABACLOUD_CREDENTIALS) { await this.alibabacloudStaticCredentialsRadio.click({ force: true }); @@ -1047,6 +1114,7 @@ export class ProvidersPage extends BasePage { // Verify the connect account page is loaded await this.verifyPageHasProwlerTitle(); + await this.verifyWizardModalOpen(); await expect(this.awsProviderRadio).toBeVisible(); await expect(this.ociProviderRadio).toBeVisible(); await expect(this.gcpProviderRadio).toBeVisible(); @@ -1061,6 +1129,7 @@ export class ProvidersPage extends BasePage { // Verify the credentials page is loaded await this.verifyPageHasProwlerTitle(); + await this.verifyWizardModalOpen(); await expect(this.roleCredentialsRadio).toBeVisible(); } @@ -1115,7 +1184,7 @@ export class ProvidersPage extends BasePage { // Verify the launch scan page is loaded await this.verifyPageHasProwlerTitle(); - await expect(this.page).toHaveURL(/\/providers\/test-connection/); + await this.verifyWizardModalOpen(); // Verify the Launch scan button is visible const launchScanButton = this.page @@ -1202,12 +1271,24 @@ export class ProvidersPage extends BasePage { async verifyUpdateCredentialsPageLoaded(): Promise { // Verify the update credentials page is loaded await this.verifyPageHasProwlerTitle(); - await expect(this.page).toHaveURL(/\/providers\/update-credentials/); + await this.verifyWizardModalOpen(); + await expect( + this.page.getByRole("button", { name: "Authenticate", exact: true }), + ).toBeVisible(); } async verifyTestConnectionPageLoaded(): Promise { // Verify the test connection page is loaded await this.verifyPageHasProwlerTitle(); - await expect(this.page).toHaveURL(/\/providers\/test-connection/); + await this.verifyWizardModalOpen(); + const testConnectionAction = this.page + .getByRole("button", { name: "Launch scan", exact: true }) + .or( + this.page.getByRole("button", { + name: "Check connection", + exact: true, + }), + ); + await expect(testConnectionAction).toBeVisible(); } } diff --git a/ui/tsconfig.json b/ui/tsconfig.json index f95fbfb358..e90024a945 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -32,10 +32,7 @@ }, "exclude": [ "node_modules", - "vitest.config.ts", - "vitest.setup.ts", - "**/*.test.ts", - "**/*.test.tsx" + "vitest.config.ts" ], "include": [ "next-env.d.ts",