diff --git a/ui/package.json b/ui/package.json index e0ec108dfc..40a51c04f1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,10 +15,10 @@ "format:check": "./node_modules/.bin/prettier --check ./app", "format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app", "prepare": "husky", - "test:e2e": "playwright test --project=chromium", - "test:e2e:ui": "playwright test --project=chromium --ui", - "test:e2e:debug": "playwright test --project=chromium --debug", - "test:e2e:headed": "playwright test --project=chromium --headed", + "test:e2e": "playwright test --project=chromium --project=sign-up", + "test:e2e:ui": "playwright test --project=chromium --project=sign-up --ui", + "test:e2e:debug": "playwright test --project=chromium --project=sign-up --debug", + "test:e2e:headed": "playwright test --project=chromium --project=sign-up --headed", "test:e2e:report": "playwright show-report", "test:e2e:install": "playwright install" }, diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 5bea00bfe7..59f49f935f 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -74,14 +74,6 @@ export default defineConfig({ name: "invite-and-manage-users.auth.setup", testMatch: "invite-and-manage-users.auth.setup.ts", }, - - // All authentication setups combined - // Runs all authentication setup files to create all user states - { - name: "all.auth.setup", - testMatch: "**/*.auth.setup.ts", - }, - // =========================================== // Test Suite Projects // =========================================== @@ -89,6 +81,12 @@ export default defineConfig({ { name: "chromium", use: { ...devices["Desktop Chrome"] }, + testMatch: "auth-login.spec.ts", + }, + // This project runs the sign-up test suite + { + name: "sign-up", + testMatch: "sign-up.spec.ts", }, ], diff --git a/ui/tests/base-page.ts b/ui/tests/base-page.ts new file mode 100644 index 0000000000..e45da93970 --- /dev/null +++ b/ui/tests/base-page.ts @@ -0,0 +1,159 @@ +import { Page, Locator, expect } from "@playwright/test"; + +/** + * Base page object class containing common functionality + * that can be shared across all page objects + */ +export abstract class BasePage { + readonly page: Page; + + // Common UI elements that appear on most pages + readonly title: Locator; + readonly loadingIndicator: Locator; + readonly themeToggle: Locator; + + constructor(page: Page) { + this.page = page; + + // Common locators that most pages share + this.title = page.locator("h1, h2, [role='heading']").first(); + this.loadingIndicator = page.getByText("Loading"); + this.themeToggle = page.getByLabel("Toggle theme"); + } + + // Common navigation methods + async goto(url: string): Promise { + await this.page.goto(url); + await this.waitForPageLoad(); + } + + async waitForPageLoad(): Promise { + await this.page.waitForLoadState("networkidle"); + } + + async refresh(): Promise { + await this.page.reload(); + await this.waitForPageLoad(); + } + + async goBack(): Promise { + await this.page.goBack(); + await this.waitForPageLoad(); + } + + // Common verification methods + async verifyPageTitle(expectedTitle: string | RegExp): Promise { + await expect(this.page).toHaveTitle(expectedTitle); + } + + async verifyLoadingState(): Promise { + await expect(this.loadingIndicator).toBeVisible(); + } + + async verifyNoLoadingState(): Promise { + await expect(this.loadingIndicator).not.toBeVisible(); + } + + // Common form interaction methods + async clearInput(input: Locator): Promise { + await input.clear(); + } + + async fillInput(input: Locator, value: string): Promise { + await input.fill(value); + } + + async clickButton(button: Locator): Promise { + await button.click(); + } + + // Common validation methods + async verifyElementVisible(element: Locator): Promise { + await expect(element).toBeVisible(); + } + + async verifyElementNotVisible(element: Locator): Promise { + await expect(element).not.toBeVisible(); + } + + async verifyElementText(element: Locator, expectedText: string): Promise { + await expect(element).toHaveText(expectedText); + } + + async verifyElementContainsText(element: Locator, expectedText: string): Promise { + await expect(element).toContainText(expectedText); + } + + // Common accessibility methods + async verifyKeyboardNavigation(elements: Locator[]): Promise { + for (const element of elements) { + await this.page.keyboard.press("Tab"); + await expect(element).toBeFocused(); + } + } + + async verifyAriaLabels(elements: { locator: Locator; expectedLabel: string }[]): Promise { + for (const { locator, expectedLabel } of elements) { + await expect(locator).toHaveAttribute("aria-label", expectedLabel); + } + } + + // Common utility methods + async getElementText(element: Locator): Promise { + return await element.textContent() || ""; + } + + async getElementValue(element: Locator): Promise { + return await element.inputValue(); + } + + async isElementVisible(element: Locator): Promise { + return await element.isVisible(); + } + + async isElementEnabled(element: Locator): Promise { + return await element.isEnabled(); + } + + // Common error handling methods + async getFormErrors(): Promise { + const errorElements = await this.page.locator('[role="alert"], .error-message, [data-testid="error"]').all(); + const errors: string[] = []; + + for (const element of errorElements) { + const text = await element.textContent(); + if (text) { + errors.push(text.trim()); + } + } + + return errors; + } + + async verifyNoErrors(): Promise { + const errors = await this.getFormErrors(); + expect(errors).toHaveLength(0); + } + + // Common wait methods + async waitForElement(element: Locator, timeout: number = 5000): Promise { + await element.waitFor({ timeout }); + } + + async waitForElementToDisappear(element: Locator, timeout: number = 5000): Promise { + await element.waitFor({ state: "hidden", timeout }); + } + + async waitForUrl(expectedUrl: string | RegExp, timeout: number = 5000): Promise { + await this.page.waitForURL(expectedUrl, { timeout }); + } + + // Common screenshot methods + async takeScreenshot(name: string): Promise { + await this.page.screenshot({ path: `screenshots/${name}.png` }); + } + + async takeElementScreenshot(element: Locator, name: string): Promise { + await element.screenshot({ path: `screenshots/${name}.png` }); + } +} diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index cc34e0cfb2..2fd6ab5e46 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -1,5 +1,5 @@ import { Page, expect } from "@playwright/test"; -import { SignInPage, SignInCredentials } from "./page-objects/sign-in-page"; +import { SignInPage, SignInCredentials } from "./sign-in/sign-in-page"; export const ERROR_MESSAGES = { INVALID_CREDENTIALS: "Invalid email or password", @@ -161,3 +161,15 @@ export async function authenticateAndSaveState( // Save authentication state await page.context().storageState({ path: storagePath }); } + +/** + * Generate a random base36 suffix of specified length + * Used for creating unique test data to avoid conflicts + */ +export function makeSuffix(len: number): string { + let s = ""; + while (s.length < len) { + s += Math.random().toString(36).slice(2); + } + return s.slice(0, len); +} diff --git a/ui/tests/page-objects/home-page.ts b/ui/tests/home/home-page.ts similarity index 80% rename from ui/tests/page-objects/home-page.ts rename to ui/tests/home/home-page.ts index b8221f10fa..0ae8312481 100644 --- a/ui/tests/page-objects/home-page.ts +++ b/ui/tests/home/home-page.ts @@ -1,7 +1,7 @@ import { Page, Locator, expect } from "@playwright/test"; +import { BasePage } from "../base-page"; -export class HomePage { - readonly page: Page; +export class HomePage extends BasePage { // Main content elements readonly mainContent: Locator; @@ -18,11 +18,10 @@ export class HomePage { readonly overviewSection: Locator; // UI elements - readonly themeToggle: Locator; readonly logo: Locator; constructor(page: Page) { - this.page = page; + super(page); // Main content elements this.mainContent = page.locator("main"); @@ -39,18 +38,12 @@ export class HomePage { this.overviewSection = page.locator('[data-testid="overview-section"]'); // UI elements - this.themeToggle = page.getByLabel("Toggle theme"); this.logo = page.locator('svg[width="300"]'); } // Navigation methods async goto(): Promise { - await this.page.goto("/"); - await this.waitForPageLoad(); - } - - async waitForPageLoad(): Promise { - await this.page.waitForLoadState("networkidle"); + await super.goto("/"); } // Verification methods @@ -93,15 +86,6 @@ export class HomePage { } // Utility methods - async refresh(): Promise { - await this.page.reload(); - await this.waitForPageLoad(); - } - - async goBack(): Promise { - await this.page.goBack(); - await this.waitForPageLoad(); - } // Accessibility methods async verifyKeyboardNavigation(): Promise { @@ -110,11 +94,6 @@ export class HomePage { await expect(this.themeToggle).toBeFocused(); } - // Wait methods - async waitForRedirect(expectedUrl: string): Promise { - await this.page.waitForURL(expectedUrl); - } - async waitForContentLoad(): Promise { await this.page.waitForFunction(() => { const main = document.querySelector("main"); diff --git a/ui/tests/auth-login.spec.ts b/ui/tests/sign-in/auth-login.spec.ts similarity index 99% rename from ui/tests/auth-login.spec.ts rename to ui/tests/sign-in/auth-login.spec.ts index 84dbc9b68f..cbc91a0cde 100644 --- a/ui/tests/auth-login.spec.ts +++ b/ui/tests/sign-in/auth-login.spec.ts @@ -20,7 +20,7 @@ import { ERROR_MESSAGES, URLS, verifyLoadingState, -} from "./helpers"; +} from "../helpers"; test.describe("Login Flow", () => { test.beforeEach(async ({ page }) => { diff --git a/ui/tests/page-objects/sign-in-page.ts b/ui/tests/sign-in/sign-in-page.ts similarity index 86% rename from ui/tests/page-objects/sign-in-page.ts rename to ui/tests/sign-in/sign-in-page.ts index 388eddb191..8fcf1a3a04 100644 --- a/ui/tests/page-objects/sign-in-page.ts +++ b/ui/tests/sign-in/sign-in-page.ts @@ -1,5 +1,6 @@ import { Page, Locator, expect } from "@playwright/test"; -import { HomePage } from "./home-page"; +import { BasePage } from "../base-page"; +import { HomePage } from "../home/home-page"; export interface SignInCredentials { email: string; @@ -11,8 +12,7 @@ export interface SocialAuthConfig { githubEnabled: boolean; } -export class SignInPage { - readonly page: Page; +export class SignInPage extends BasePage { readonly homePage: HomePage; // Form elements @@ -31,20 +31,17 @@ export class SignInPage { readonly backButton: Locator; // UI elements - readonly title: Locator; readonly logo: Locator; - readonly themeToggle: Locator; // Error messages readonly errorMessages: Locator; - readonly loadingIndicator: Locator; // SAML specific elements readonly samlModeTitle: Locator; readonly samlEmailInput: Locator; constructor(page: Page) { - this.page = page; + super(page); this.homePage = new HomePage(page); // Form elements @@ -63,13 +60,10 @@ export class SignInPage { this.backButton = page.getByText("Back"); // UI elements - this.title = page.getByText("Sign in", { exact: true }); this.logo = page.locator('svg[width="300"]'); - this.themeToggle = page.getByLabel("Toggle theme"); // Error messages this.errorMessages = page.locator('[role="alert"], .error-message, [data-testid="error"]'); - this.loadingIndicator = page.getByText("Loading"); // SAML specific elements this.samlModeTitle = page.getByText("Sign in with SAML SSO"); @@ -78,12 +72,7 @@ export class SignInPage { // Navigation methods async goto(): Promise { - await this.page.goto("/sign-in"); - await this.waitForPageLoad(); - } - - async waitForPageLoad(): Promise { - await this.page.waitForLoadState("networkidle"); + await super.goto("/sign-in"); } // Form interaction methods @@ -148,7 +137,7 @@ export class SignInPage { async verifyPageLoaded(): Promise { await expect(this.page).toHaveTitle(/Prowler/); await expect(this.logo).toBeVisible(); - await expect(this.title).toBeVisible(); + await expect(this.page.getByText("Sign in", { exact: true })).toBeVisible(); } async verifyFormElements(): Promise { @@ -188,13 +177,13 @@ export class SignInPage { } async verifyNormalModeActive(): Promise { - await expect(this.title).toBeVisible(); + await expect(this.page.getByText("Sign in", { exact: true })).toBeVisible(); await expect(this.passwordInput).toBeVisible(); } async verifyLoadingState(): Promise { await expect(this.loginButton).toHaveAttribute("aria-disabled", "true"); - await expect(this.loadingIndicator).toBeVisible(); + await super.verifyLoadingState(); } async verifyFormValidation(): Promise { @@ -239,30 +228,7 @@ export class SignInPage { return emailValue.length > 0 && passwordValue.length > 0; } - async getFormErrors(): Promise { - const errorElements = await this.errorMessages.all(); - const errors: string[] = []; - - for (const element of errorElements) { - const text = await element.textContent(); - if (text) { - errors.push(text.trim()); - } - } - - return errors; - } - // Browser interaction methods - async refresh(): Promise { - await this.page.reload(); - await this.waitForPageLoad(); - } - - async goBack(): Promise { - await this.page.goBack(); - await this.waitForPageLoad(); - } // Session management methods async logout(): Promise { @@ -271,7 +237,7 @@ export class SignInPage { async verifyLogoutSuccess(): Promise { await expect(this.page).toHaveURL("/sign-in"); - await expect(this.title).toBeVisible(); + await expect(this.page.getByText("Sign in", { exact: true })).toBeVisible(); } // Advanced interaction methods diff --git a/ui/tests/sign-up/sign-up-page.ts b/ui/tests/sign-up/sign-up-page.ts new file mode 100644 index 0000000000..b55366bd51 --- /dev/null +++ b/ui/tests/sign-up/sign-up-page.ts @@ -0,0 +1,121 @@ +import { Page, Locator, expect } from "@playwright/test"; +import { BasePage } from "../base-page"; + +export interface SignUpData { + name: string; + email: string; + password: string; + confirmPassword: string; + company?: string; + invitationToken?: string | null; + acceptTerms?: boolean; +} + +export class SignUpPage extends BasePage { + + // Form inputs + readonly nameInput: Locator; + readonly companyInput: Locator; + readonly emailInput: Locator; + readonly passwordInput: Locator; + readonly confirmPasswordInput: Locator; + readonly invitationTokenInput: Locator; + + // UI elements + readonly submitButton: Locator; + readonly loginLink: Locator; + readonly termsCheckbox: Locator; + + constructor(page: Page) { + super(page); + + // Prefer stable name attributes to avoid label ambiguity in composed inputs + this.nameInput = page.locator('input[name="name"]'); + this.companyInput = page.locator('input[name="company"]'); + this.emailInput = page.getByLabel("Email"); + this.passwordInput = page.locator('input[name="password"]'); + this.confirmPasswordInput = page.locator('input[name="confirmPassword"]'); + this.invitationTokenInput = page.locator('input[name="invitationToken"]'); + + this.submitButton = page.getByRole("button", { name: "Sign up" }); + this.loginLink = page.getByRole("link", { name: "Log in" }); + this.termsCheckbox = page.getByText("I agree with the"); + } + + async goto(): Promise { + await super.goto("/sign-up"); + } + + async verifyPageLoaded(): Promise { + await expect(this.page.getByText("Sign up", { exact: true })).toBeVisible(); + await expect(this.emailInput).toBeVisible(); + await expect(this.submitButton).toBeVisible(); + } + + async fillName(name: string): Promise { + await this.nameInput.fill(name); + } + + async fillCompany(company?: string): Promise { + if (company) { + await this.companyInput.fill(company); + } + } + + async fillEmail(email: string): Promise { + await this.emailInput.fill(email); + } + + async fillPassword(password: string): Promise { + await this.passwordInput.fill(password); + } + + async fillConfirmPassword(confirmPassword: string): Promise { + await this.confirmPasswordInput.fill(confirmPassword); + } + + async fillInvitationToken(token?: string | null): Promise { + if (token) { + await this.invitationTokenInput.fill(token); + } + } + + async acceptTermsIfPresent(accept: boolean = true): Promise { + // Only in cloud env; check presence before interacting + if (await this.termsCheckbox.isVisible()) { + if (accept) { + await this.termsCheckbox.click(); + } + } + } + + async submit(): Promise { + await this.submitButton.click(); + } + + async signup(data: SignUpData): Promise { + await this.fillName(data.name); + await this.fillCompany(data.company); + await this.fillEmail(data.email); + await this.fillPassword(data.password); + await this.fillConfirmPassword(data.confirmPassword); + await this.fillInvitationToken(data.invitationToken ?? undefined); + await this.acceptTermsIfPresent(data.acceptTerms ?? true); + await this.submit(); + } + + async verifyLoadingState(): Promise { + await expect(this.submitButton).toHaveAttribute("aria-disabled", "true"); + await super.verifyLoadingState(); + } + + async verifyRedirectToLogin(): Promise { + await expect(this.page).toHaveURL("/sign-in"); + } + + async verifyRedirectToEmailVerification(): Promise { + await expect(this.page).toHaveURL("/email-verification"); + } +} + + diff --git a/ui/tests/sign-up/sign-up.spec.ts b/ui/tests/sign-up/sign-up.spec.ts new file mode 100644 index 0000000000..ef2c0ae3d7 --- /dev/null +++ b/ui/tests/sign-up/sign-up.spec.ts @@ -0,0 +1,42 @@ +import { test } from "@playwright/test"; +import { SignUpPage } from "./sign-up-page"; +import { SignInPage } from "../sign-in/sign-in-page"; +import { makeSuffix } from "../helpers"; + +test.describe("Sign Up Flow", () => { + test("should register a new user successfully", { tag: ['@critical'] }, async ({ page }) => { + // Initialize page objects for sign-up and sign-in flows + const signUpPage = new SignUpPage(page); + await signUpPage.goto(); + + // Generate unique test data to avoid conflicts with existing users + // Create a base36 random suffix of exactly 10 characters for uniqueness + const suffix = makeSuffix(10); + const uniqueEmail = `e2e+${suffix}@prowler.com`; + + // Fill and submit the sign-up form with valid test data + // Name is exactly 19 characters to meet the max length requirement (20 chars) + await signUpPage.signup({ + name: `E2E User ${suffix}`, // 9 chars + 10 chars = 19 chars + company: `Test E2E Co ${suffix}`, + email: uniqueEmail, + password: "Thisisapassword123@", + confirmPassword: "Thisisapassword123@", + acceptTerms: true, + }); + + // Verify successful sign-up redirects to login page (OSS environment) + await signUpPage.verifyRedirectToLogin(); + + // Verify the newly created user can successfully log in + // This ensures the user account was properly created and is functional + const signInPage = new SignInPage(page); + await signInPage.login({ + email: uniqueEmail, + password: "Thisisapassword123@", + }); + await signInPage.verifySuccessfulLogin(); + }); +}); + +