test(ui): update E2E page objects and improve test stability (#10158)

This commit is contained in:
Alejandro Bailo
2026-02-25 13:30:54 +01:00
committed by GitHub
parent f403971885
commit 2a58781e37
6 changed files with 242 additions and 175 deletions

View File

@@ -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

View File

@@ -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();
},
);

View File

@@ -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");
},
);

View File

@@ -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<void> {
// 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<void> {
@@ -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");
}

View File

@@ -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<void> {
await this.clickAddProvider();
await this.verifyWizardModalOpen();
}
async closeProviderWizardModal(): Promise<void> {
await this.page.keyboard.press("Escape");
await expect(this.wizardModal).not.toBeVisible();
}
async verifyWizardModalOpen(): Promise<void> {
await expect(this.wizardModal).toBeVisible();
await expect(this.wizardTitle).toBeVisible();
}
async advanceWizardStep(): Promise<void> {
await this.clickNext();
}
private async selectProviderRadio(radio: Locator): Promise<void> {
// 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<void> {
await this.page
.getByRole("button", {
name: "Add A Single AWS Cloud Account",
exact: true,
})
.click();
}
async selectAWSOrganizationsMethod(): Promise<void> {
await this.page
.getByRole("button", {
name: "Add Multiple Accounts With AWS Organizations",
exact: true,
})
.click();
}
async verifyOrganizationsAuthenticationStepLoaded(): Promise<void> {
await this.verifyWizardModalOpen();
await expect(
this.page.getByRole("heading", {
name: /Authentication Details/i,
}),
).toBeVisible();
}
async verifyOrganizationsAccountSelectionStepLoaded(): Promise<void> {
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<void> {
await this.verifyWizardModalOpen();
await expect(this.page.getByText(/Accounts Connected!/i)).toBeVisible();
}
async chooseOrganizationsScanSchedule(
option: "daily" | "single",
): Promise<void> {
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<void> {
// 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<void> {
// 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<void> => {
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<void> {
// Ensure we are on the add-credentials page where the selector exists
private async handleLaunchScanCompletion(): Promise<void> {
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<void> {
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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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();
}
}

View File

@@ -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",