From 255ce0e866cfd74ee8c6310adbb6c0184423a98f Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Tue, 27 Jan 2026 12:53:24 +0100 Subject: [PATCH] test(ui-e2e): reorganize auth tests and add documentation (#9788) Co-authored-by: pedrooot --- skills/prowler-test-ui/SKILL.md | 95 +++- ui/package.json | 8 +- ui/playwright.config.ts | 17 +- ui/tests/auth-middleware-error.spec.ts | 92 ---- ui/tests/auth-refresh-token.spec.ts | 124 ----- ui/tests/auth-session-error-message.spec.ts | 102 ---- ui/tests/auth/auth-middleware.spec.ts | 66 +++ ui/tests/auth/auth-session-errors.spec.ts | 88 ++++ ui/tests/auth/auth-token-refresh.spec.ts | 78 +++ ui/tests/auth/auth.md | 231 +++++++++ ui/tests/base-page.ts | 18 +- ui/tests/helpers.ts | 142 +----- ui/tests/home/home-page.ts | 27 +- ui/tests/invitations/invitations.spec.ts | 2 +- ui/tests/profile/profile-page.ts | 11 +- ui/tests/setups/admin.auth.setup.ts | 5 +- .../invite-and-manage-users.auth.setup.ts | 5 +- ui/tests/setups/manage-account.auth.setup.ts | 5 +- .../manage-cloud-providers.auth.setup.ts | 5 +- .../setups/manage-integrations.auth.setup.ts | 5 +- ui/tests/setups/manage-scans.auth.setup.ts | 5 +- .../setups/unlimited-visibility.auth.setup.ts | 5 +- .../sign-in-base-page.ts} | 204 ++++++-- ui/tests/sign-in-base/sign-in-base.md | 446 ++++++++++++++++++ ui/tests/sign-in-base/sign-in-base.spec.ts | 262 ++++++++++ ui/tests/sign-in/auth-login.spec.ts | 256 ---------- ui/tests/sign-up/sign-up-page.ts | 11 +- ui/tests/sign-up/sign-up.spec.ts | 2 +- 28 files changed, 1506 insertions(+), 811 deletions(-) delete mode 100644 ui/tests/auth-middleware-error.spec.ts delete mode 100644 ui/tests/auth-refresh-token.spec.ts delete mode 100644 ui/tests/auth-session-error-message.spec.ts create mode 100644 ui/tests/auth/auth-middleware.spec.ts create mode 100644 ui/tests/auth/auth-session-errors.spec.ts create mode 100644 ui/tests/auth/auth-token-refresh.spec.ts create mode 100644 ui/tests/auth/auth.md rename ui/tests/{sign-in/sign-in-page.ts => sign-in-base/sign-in-base-page.ts} (56%) create mode 100644 ui/tests/sign-in-base/sign-in-base.md create mode 100644 ui/tests/sign-in-base/sign-in-base.spec.ts delete mode 100644 ui/tests/sign-in/auth-login.spec.ts diff --git a/skills/prowler-test-ui/SKILL.md b/skills/prowler-test-ui/SKILL.md index 67dab1194f..558525932d 100644 --- a/skills/prowler-test-ui/SKILL.md +++ b/skills/prowler-test-ui/SKILL.md @@ -26,11 +26,43 @@ ui/tests/ └── {page-name}/ ├── {page-name}-page.ts # Page Object Model ├── {page-name}.spec.ts # ALL tests (single file per feature) - └── {page-name}.md # Test documentation + └── {page-name}.md # Test documentation (MANDATORY - sync with spec.ts) ``` --- +## MANDATORY Checklist (Create or Modify Tests) + +**⚠️ ALWAYS verify BEFORE completing any E2E task:** + +### When CREATING new tests: +- [ ] `{page-name}-page.ts` - Page Object created/updated +- [ ] `{page-name}.spec.ts` - Tests added with correct tags (@TEST-ID) +- [ ] `{page-name}.md` - Documentation created with ALL test cases +- [ ] Test IDs in `.md` match tags in `.spec.ts` + +### When MODIFYING existing tests: +- [ ] `{page-name}.md` MUST be updated if: + - Test cases were added/removed + - Test flow changed (steps) + - Preconditions or expected results changed + - Tags or priorities changed +- [ ] Test IDs synchronized between `.md` and `.spec.ts` + +### Quick validation: +```bash +# Verify .md exists for each test folder +ls ui/tests/{feature}/{feature}.md + +# Verify test IDs match +grep -o "@[A-Z]*-E2E-[0-9]*" ui/tests/{feature}/{feature}.spec.ts | sort -u +grep -o "\`[A-Z]*-E2E-[0-9]*\`" ui/tests/{feature}/{feature}.md | sort -u +``` + +**❌ An E2E change is NOT considered complete without updating the corresponding .md file** + +--- + ## MCP Workflow - CRITICAL **⚠️ MANDATORY: If Playwright MCP tools are available, ALWAYS use them BEFORE creating tests.** @@ -45,6 +77,33 @@ ui/tests/ --- +## Wait Strategies (CRITICAL) + +**⚠️ NEVER use `networkidle` - it causes flaky tests!** + +| Strategy | Use Case | +|----------|----------| +| ❌ `networkidle` | NEVER - flaky with polling/WebSockets | +| ⚠️ `load` | Only when absolutely necessary | +| ✅ `expect(element).toBeVisible()` | PREFERRED - wait for specific UI state | +| ✅ `page.waitForURL()` | Wait for navigation | +| ✅ `pageObject.verifyPageLoaded()` | BEST - encapsulated verification | + +**GOOD:** +```typescript +await homePage.verifyPageLoaded(); +await expect(page).toHaveURL("/dashboard"); +await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible(); +``` + +**BAD:** +```typescript +await page.waitForLoadState("networkidle"); // ❌ FLAKY +await page.waitForTimeout(2000); // ❌ ARBITRARY WAIT +``` + +--- + ## Prowler Base Page ```typescript @@ -55,11 +114,12 @@ export class BasePage { async goto(path: string): Promise { await this.page.goto(path); - await this.page.waitForLoadState("networkidle"); + // Child classes should override verifyPageLoaded() to wait for specific elements } - async waitForPageLoad(): Promise { - await this.page.waitForLoadState("networkidle"); + // Override in child classes to wait for page-specific elements + async verifyPageLoaded(): Promise { + await expect(this.page.locator("main")).toBeVisible(); } // Prowler-specific: notification handling @@ -78,6 +138,33 @@ export class BasePage { --- +## Page Navigation Verification Pattern + +**⚠️ URL assertions belong in Page Objects, NOT in tests!** + +When verifying redirects or page navigation, create dedicated methods in the target Page Object: + +```typescript +// ✅ GOOD - In SignInPage +async verifyOnSignInPage(): Promise { + await expect(this.page).toHaveURL(/\/sign-in/); + await expect(this.pageTitle).toBeVisible(); +} + +// ✅ GOOD - In test +await homePage.goto(); // Try to access protected route +await signInPage.verifyOnSignInPage(); // Verify redirect + +// ❌ BAD - Direct assertions in test +await homePage.goto(); +await expect(page).toHaveURL(/\/sign-in/); // Should be in Page Object +await expect(page.getByText("Sign in")).toBeVisible(); +``` + +**Naming convention:** `verifyOn{PageName}Page()` for redirect verification methods. + +--- + ## Prowler-Specific Pages ### Providers Page diff --git a/ui/package.json b/ui/package.json index 9ef6b93d90..a9bedfca6a 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 --project=sign-up --project=providers --project=invitations --project=scans", - "test:e2e:ui": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --project=scans --ui", - "test:e2e:debug": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --project=scans --debug", - "test:e2e:headed": "playwright test --project=chromium --project=sign-up --project=providers --project=invitations --project=scans --headed", + "test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans", + "test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --ui", + "test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --debug", + "test:e2e:headed": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --headed", "test:e2e:report": "playwright show-report", "test:e2e:install": "playwright install", "audit:fix": "pnpm audit fix" diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 132191c6cf..8c9d053944 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -88,10 +88,18 @@ export default defineConfig({ // Test Suite Projects // =========================================== // These projects run the actual test suites + + // This project runs the sign-in-base test suite (form, navigation, accessibility) { - name: "chromium", + name: "sign-in-base", use: { ...devices["Desktop Chrome"] }, - testMatch: "auth-login.spec.ts", + testMatch: /sign-in-base\/.*\.spec\.ts/, + }, + // This project runs the auth test suite (middleware, session, token refresh) + { + name: "auth", + use: { ...devices["Desktop Chrome"] }, + testMatch: /auth\/.*\.spec\.ts/, }, // This project runs the sign-up test suite { @@ -129,8 +137,9 @@ export default defineConfig({ AUTH_SECRET: process.env.AUTH_SECRET || "fallback-ci-secret-for-testing", AUTH_TRUST_HOST: process.env.AUTH_TRUST_HOST || "true", NEXTAUTH_URL: process.env.NEXTAUTH_URL || "http://localhost:3000", - E2E_USER: process.env.E2E_USER || "e2e@prowler.com", - E2E_PASSWORD: process.env.E2E_PASSWORD || "Thisisapassword123@", + E2E_ADMIN_USER: process.env.E2E_ADMIN_USER || "e2e@prowler.com", + E2E_ADMIN_PASSWORD: + process.env.E2E_ADMIN_PASSWORD || "Thisisapassword123@", }, }, }); diff --git a/ui/tests/auth-middleware-error.spec.ts b/ui/tests/auth-middleware-error.spec.ts deleted file mode 100644 index a69fa2ccdc..0000000000 --- a/ui/tests/auth-middleware-error.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { - goToLogin, - login, - verifySuccessfulLogin, - verifySessionValid, - TEST_CREDENTIALS, - URLS, -} from "./helpers"; - -test.describe("Middleware Error Handling", () => { - test("should allow access to public routes without session", async ({ - page, - context, - }) => { - // Ensure no session exists - await context.clearCookies(); - - // Try to access login page (public route) - await page.goto(URLS.LOGIN); - await expect(page).toHaveURL(URLS.LOGIN); - await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); - - // Try to access sign-up page (public route) - await page.goto(URLS.SIGNUP); - await expect(page).toHaveURL(URLS.SIGNUP); - }); - - test("should maintain protection after session error", async ({ - page, - context, - }) => { - // Login - await goToLogin(page); - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - - // Navigate to a protected page - await page.goto("/providers"); - await expect(page).toHaveURL("/providers"); - - // Simulate session error by corrupting cookie - const cookies = await context.cookies(); - const sessionCookie = cookies.find((c) => - c.name.includes("authjs.session-token"), - ); - - if (sessionCookie) { - await context.clearCookies(); - await context.addCookies([ - { - ...sessionCookie, - value: "invalid-session-token", - }, - ]); - - // Try to navigate to another protected page - await page.goto("/scans", { waitUntil: "networkidle" }); - - // Should be redirected to login (may include callbackUrl) - await expect(page).toHaveURL(/\/sign-in/); - } - }); - - test("should handle permission-based redirects", async ({ page }) => { - // Login with valid credentials - await goToLogin(page); - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - - // Get user permissions using helper - const session = await verifySessionValid(page); - const permissions = session.user.permissions; - - // Test billing route if user doesn't have permission - if (!permissions.manage_billing) { - await page.goto("/billing", { waitUntil: "networkidle" }); - - // Should be redirected to profile (as per middleware logic) - await expect(page).toHaveURL("/profile"); - } - - // Test integrations route if user doesn't have permission - if (!permissions.manage_integrations) { - await page.goto("/integrations", { waitUntil: "networkidle" }); - - // Should be redirected to profile (as per middleware logic) - await expect(page).toHaveURL("/profile"); - } - }); - -}); diff --git a/ui/tests/auth-refresh-token.spec.ts b/ui/tests/auth-refresh-token.spec.ts deleted file mode 100644 index 0b5a66ab17..0000000000 --- a/ui/tests/auth-refresh-token.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { - goToLogin, - login, - verifySuccessfulLogin, - getSession, - verifySessionValid, - TEST_CREDENTIALS, - URLS, -} from "./helpers"; - -test.describe("Token Refresh Flow", () => { - test("should refresh access token when expired", async ({ page }) => { - // Login first - await goToLogin(page); - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - - // Get initial session using helper - const initialSession = await verifySessionValid(page); - const initialAccessToken = initialSession.accessToken; - - // Wait for some time to allow token to potentially expire - // In a real scenario, you might want to manipulate the token expiry - await page.waitForTimeout(2000); - - // Make a request that requires authentication - // This should trigger token refresh if needed - await page.reload(); - await page.waitForLoadState("networkidle"); - - // Verify we're still authenticated - await expect(page).toHaveURL(URLS.DASHBOARD); - - // Get session after potential refresh using helper - const refreshedSession = await verifySessionValid(page); - - // User data should be maintained - expect(refreshedSession.user.email).toBe(initialSession.user.email); - expect(refreshedSession.userId).toBe(initialSession.userId); - expect(refreshedSession.tenantId).toBe(initialSession.tenantId); - }); - - test("should handle concurrent requests with token refresh", async ({ - page, - }) => { - // Login - await goToLogin(page); - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - - // Make multiple concurrent requests to the API - const requests = Array(5) - .fill(null) - .map(() => page.request.get("/api/auth/session")); - - const responses = await Promise.all(requests); - - // All requests should succeed - verify using helper - for (const response of responses) { - expect(response.ok()).toBeTruthy(); - const session = await response.json(); - - // Validate session structure - expect(session).toBeTruthy(); - expect(session.user).toBeTruthy(); - expect(session.accessToken).toBeTruthy(); - expect(session.refreshToken).toBeTruthy(); - expect(session.error).toBeUndefined(); - } - }); - - test("should preserve user permissions after token refresh", async ({ - page, - }) => { - // Login - await goToLogin(page); - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - - // Get initial session with permissions using helper - const initialSession = await verifySessionValid(page); - const initialPermissions = initialSession.user.permissions; - - // Reload page to potentially trigger token refresh - await page.reload(); - await page.waitForLoadState("networkidle"); - - // Get session after reload using helper - const refreshedSession = await verifySessionValid(page); - - // Permissions should be preserved - expect(refreshedSession.user.permissions).toEqual(initialPermissions); - - // All user data should be preserved - expect(refreshedSession.user.email).toBe(initialSession.user.email); - expect(refreshedSession.user.name).toBe(initialSession.user.name); - expect(refreshedSession.user.companyName).toBe( - initialSession.user.companyName, - ); - }); - - test("should clear session when cookies are removed", async ({ - page, - context, - }) => { - // Login - await goToLogin(page); - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - - // Verify session is valid using helper - await verifySessionValid(page); - - // Clear all cookies to simulate complete session expiry - await context.clearCookies(); - - // Verify session is null after clearing cookies - const expiredSession = await getSession(page); - expect(expiredSession).toBeNull(); - - // Note: Middleware redirect behavior is tested in auth-middleware-error.spec.ts - }); -}); diff --git a/ui/tests/auth-session-error-message.spec.ts b/ui/tests/auth-session-error-message.spec.ts deleted file mode 100644 index 07f96abc1d..0000000000 --- a/ui/tests/auth-session-error-message.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { - goToLogin, - login, - verifySuccessfulLogin, - TEST_CREDENTIALS, - URLS, -} from "./helpers"; - -test.describe("Session Error Messages", () => { - test("should show RefreshAccessTokenError message", async ({ page }) => { - // Navigate to sign-in with RefreshAccessTokenError query param - await page.goto("/sign-in?error=RefreshAccessTokenError"); - - // Wait for toast notification - await page.waitForTimeout(200); - - // Verify error toast appears - const toast = page.locator('[role="status"], [role="alert"]').first(); - - const isVisible = await toast.isVisible().catch(() => false); - - if (isVisible) { - const text = await toast.textContent(); - expect(text).toContain("Session Expired"); - expect(text).toContain("Please sign in again"); - } - - // Verify sign-in form is displayed - await expect(page.getByLabel("Email")).toBeVisible(); - await expect(page.getByLabel("Password")).toBeVisible(); - }); - - test("should show MissingRefreshToken error message", async ({ page }) => { - // Navigate to sign-in with MissingRefreshToken query param - await page.goto("/sign-in?error=MissingRefreshToken"); - - // Wait for toast notification - await page.waitForTimeout(200); - - // Verify error toast appears - const toast = page.locator('[role="status"], [role="alert"]').first(); - - const isVisible = await toast.isVisible().catch(() => false); - - if (isVisible) { - const text = await toast.textContent(); - expect(text).toContain("Session Error"); - } - - // Verify sign-in form is displayed - await expect(page.getByLabel("Email")).toBeVisible(); - }); - - test("should show generic error for unknown error types", async ({ page }) => { - // Navigate to sign-in with unknown error type - await page.goto("/sign-in?error=UnknownError"); - - // Wait for toast notification - await page.waitForTimeout(200); - - // Verify generic error toast appears - const toast = page.locator('[role="status"], [role="alert"]').first(); - - const isVisible = await toast.isVisible().catch(() => false); - - if (isVisible) { - const text = await toast.textContent(); - expect(text).toContain("Authentication Error"); - expect(text).toContain("Please sign in again"); - } - }); - - test("should include callbackUrl in redirect", async ({ - page, - context, - }) => { - // Login first - await goToLogin(page); - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - - // Navigate to a specific page - await page.goto("/scans"); - await page.waitForLoadState("networkidle"); - - // Clear cookies to simulate session expiry - await context.clearCookies(); - - // Try to navigate to a different protected route - await page.goto("/providers"); - - // Should be redirected to login with callbackUrl - await expect(page).toHaveURL(/\/sign-in\?.*callbackUrl=/); - - // Verify callbackUrl contains the attempted route - const url = new URL(page.url()); - const callbackUrl = url.searchParams.get("callbackUrl"); - expect(callbackUrl).toBe("/providers"); - }); - -}); diff --git a/ui/tests/auth/auth-middleware.spec.ts b/ui/tests/auth/auth-middleware.spec.ts new file mode 100644 index 0000000000..16ef42997a --- /dev/null +++ b/ui/tests/auth/auth-middleware.spec.ts @@ -0,0 +1,66 @@ +import { 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"; +import { SignUpPage } from "../sign-up/sign-up-page"; + +test.describe("Middleware Error Handling", () => { + // Increase timeout for tests that involve multiple navigations under load + test.setTimeout(60000); + + test( + "should allow access to public routes without session", + { tag: ["@e2e", "@auth", "@middleware", "@AUTH-MW-E2E-001"] }, + async ({ page, context }) => { + const signInPage = new SignInPage(page); + const signUpPage = new SignUpPage(page); + + await context.clearCookies(); + + await signInPage.goto(); + await signInPage.verifyOnSignInPage(); + + await signUpPage.goto(); + await signUpPage.verifyPageLoaded(); + }, + ); + + test( + "should maintain protection after session error", + { tag: ["@e2e", "@auth", "@middleware", "@AUTH-MW-E2E-002"] }, + async ({ page, context }) => { + const signInPage = new SignInPage(page); + const providersPage = new ProvidersPage(page); + const scansPage = new ScansPage(page); + + await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID); + + await providersPage.goto(); + await providersPage.verifyPageLoaded(); + + const cookies = await context.cookies(); + const sessionCookie = cookies.find((c) => + c.name.includes("authjs.session-token"), + ); + + if (sessionCookie) { + await context.clearCookies(); + await context.addCookies([ + { + ...sessionCookie, + value: "invalid-session-token", + }, + ]); + + await scansPage.goto(); + // With invalid session, should redirect to sign-in + await signInPage.verifyOnSignInPage(); + } + }, + ); + + // Note: Billing and integrations permission tests removed + // These features only exist in Prowler Cloud, not in the open-source version +}); diff --git a/ui/tests/auth/auth-session-errors.spec.ts b/ui/tests/auth/auth-session-errors.spec.ts new file mode 100644 index 0000000000..888d1e88ff --- /dev/null +++ b/ui/tests/auth/auth-session-errors.spec.ts @@ -0,0 +1,88 @@ +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", () => { + // Increase timeout for tests that involve session operations under load + test.setTimeout(60000); + + test( + "should show RefreshAccessTokenError message", + { tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-001"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + + await signInPage.gotoWithError("RefreshAccessTokenError"); + + const { isVisible, text } = await signInPage.waitForToast(); + if (isVisible && text) { + expect(text).toContain("Session Expired"); + expect(text).toContain("Please sign in again"); + } + + await signInPage.verifyFormElements(); + }, + ); + + test( + "should show MissingRefreshToken error message", + { tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-002"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + + await signInPage.gotoWithError("MissingRefreshToken"); + + const { isVisible, text } = await signInPage.waitForToast(); + if (isVisible && text) { + expect(text).toContain("Session Error"); + } + + await expect(signInPage.emailInput).toBeVisible(); + }, + ); + + test( + "should show generic error for unknown error types", + { tag: ["@e2e", "@auth", "@session", "@AUTH-SESSION-E2E-003"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + + await signInPage.gotoWithError("UnknownError"); + + const { isVisible, text } = await signInPage.waitForToast(); + if (isVisible && text) { + expect(text).toContain("Authentication Error"); + expect(text).toContain("Please sign in again"); + } + }, + ); + + test( + "should include callbackUrl in redirect", + { 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(); + + // Clear cookies to simulate session expiry + await context.clearCookies(); + + // Try to navigate to a different protected route + // Use gotoFresh to ensure middleware runs without cached state + await providersPage.gotoFresh("/providers"); + + // Should be redirected to login with callbackUrl + await signInPage.verifyRedirectWithCallback("/providers"); + }, + ); +}); diff --git a/ui/tests/auth/auth-token-refresh.spec.ts b/ui/tests/auth/auth-token-refresh.spec.ts new file mode 100644 index 0000000000..36693bb1f1 --- /dev/null +++ b/ui/tests/auth/auth-token-refresh.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; + +import { getSession, TEST_CREDENTIALS, verifySessionValid } from "../helpers"; +import { HomePage } from "../home/home-page"; +import { SignInPage } from "../sign-in-base/sign-in-base-page"; + +// Note: HomePage is still needed for verifyPageLoaded after reload in some tests + +test.describe("Token Refresh Flow", () => { + // Increase timeout for tests that involve session operations under load + test.setTimeout(60000); + + test( + "should refresh access token when expired", + { tag: ["@e2e", "@auth", "@token", "@AUTH-TOKEN-E2E-001"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + const homePage = new HomePage(page); + + await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID); + + const initialSession = await verifySessionValid(page); + + await page.reload(); + await homePage.verifyPageLoaded(); + + const refreshedSession = await verifySessionValid(page); + + expect(refreshedSession.user.email).toBe(initialSession.user.email); + expect(refreshedSession.userId).toBe(initialSession.userId); + expect(refreshedSession.tenantId).toBe(initialSession.tenantId); + }, + ); + + test( + "should preserve user permissions after token refresh", + { tag: ["@e2e", "@auth", "@token", "@AUTH-TOKEN-E2E-002"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + const homePage = new HomePage(page); + + await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID); + + const initialSession = await verifySessionValid(page); + const initialPermissions = initialSession.user.permissions; + + await page.reload(); + await homePage.verifyPageLoaded(); + + const refreshedSession = await verifySessionValid(page); + + expect(refreshedSession.user.permissions).toEqual(initialPermissions); + + expect(refreshedSession.user.email).toBe(initialSession.user.email); + expect(refreshedSession.user.name).toBe(initialSession.user.name); + expect(refreshedSession.user.companyName).toBe( + initialSession.user.companyName, + ); + }, + ); + + test( + "should clear session when cookies are removed", + { tag: ["@e2e", "@auth", "@token", "@AUTH-TOKEN-E2E-003"] }, + async ({ page, context }) => { + const signInPage = new SignInPage(page); + + await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID); + + await verifySessionValid(page); + + await context.clearCookies(); + + const expiredSession = await getSession(page); + expect(expiredSession).toBeNull(); + }, + ); +}); diff --git a/ui/tests/auth/auth.md b/ui/tests/auth/auth.md new file mode 100644 index 0000000000..6df4b81e18 --- /dev/null +++ b/ui/tests/auth/auth.md @@ -0,0 +1,231 @@ +### E2E Tests: Authentication System + +**Suite ID:** `AUTH-E2E` +**Feature:** Authentication middleware, session management, and token refresh. + +--- + +## Test Case: `AUTH-MW-E2E-001` - Allow access to public routes without session + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @auth, @middleware + +**Description/Objective:** Verify public routes are accessible without authentication. + +**Preconditions:** +- Application is running. +- No active session (cookies cleared). + +### Flow Steps: +1. Clear all cookies. +2. Navigate to /sign-in. +3. Verify page loads. +4. Navigate to /sign-up. +5. Verify page loads. + +### Expected Result: +- Public routes are accessible without authentication. + +--- + +## Test Case: `AUTH-MW-E2E-002` - Maintain protection after session error + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @auth, @middleware + +**Description/Objective:** Verify protected routes remain protected after session invalidation. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Log in with valid credentials. +2. Navigate to a protected route. +3. Invalidate session (replace cookie with invalid token). +4. Navigate to another protected route. +5. Verify redirect to sign-in. + +### Expected Result: +- Invalid session results in redirect to sign-in. + +--- + +## Test Case: `AUTH-SESSION-E2E-001` - Show RefreshAccessTokenError message + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @auth, @session + +**Description/Objective:** Verify that RefreshAccessTokenError displays appropriate toast message. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to /sign-in with error=RefreshAccessTokenError query parameter. +2. Check for toast notification. +3. Verify form elements are still visible. + +### Expected Result: +- Toast shows "Session Expired" message with "Please sign in again". +- Sign-in form is displayed and functional. + +--- + +## Test Case: `AUTH-SESSION-E2E-002` - Show MissingRefreshToken error message + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @auth, @session + +**Description/Objective:** Verify that MissingRefreshToken error displays appropriate toast message. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to /sign-in with error=MissingRefreshToken query parameter. +2. Check for toast notification. +3. Verify email input is visible. + +### Expected Result: +- Toast shows "Session Error" message. +- Sign-in form is displayed. + +--- + +## Test Case: `AUTH-SESSION-E2E-003` - Show generic error for unknown error types + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @auth, @session + +**Description/Objective:** Verify that unknown error types display a generic authentication error message. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to /sign-in with error=UnknownError query parameter. +2. Check for toast notification. + +### Expected Result: +- Toast shows "Authentication Error" message with "Please sign in again". + +--- + +## Test Case: `AUTH-SESSION-E2E-004` - Include callbackUrl in redirect + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @auth, @session + +**Description/Objective:** Verify that callbackUrl is preserved when redirecting to sign-in after session expiry. + +**Preconditions:** +- Application is running. +- Valid test user credentials. + +### Flow Steps: +1. Log in with valid credentials. +2. Navigate to a protected route (/scans). +3. Clear cookies to simulate session expiry. +4. Navigate to another protected route (/providers). +5. Verify redirect to sign-in includes callbackUrl parameter. + +### Expected Result: +- URL contains callbackUrl=/providers parameter. +- User can sign in and be redirected back to the original destination. + +--- + +## Test Case: `AUTH-TOKEN-E2E-001` - Refresh access token when expired + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @auth, @token + +**Description/Objective:** Verify that session is maintained after page reload (token refresh). + +**Preconditions:** +- Application is running. +- Valid test user credentials. + +### Flow Steps: +1. Log in with valid credentials. +2. Verify home page is loaded. +3. Capture initial session data. +4. Reload the page. +5. Verify session is still valid with same user data. + +### Expected Result: +- Session persists after reload. +- User email, userId, and tenantId remain the same. + +--- + +## Test Case: `AUTH-TOKEN-E2E-002` - Preserve user permissions after token refresh + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @auth, @token + +**Description/Objective:** Verify that user permissions are preserved after token refresh. + +**Preconditions:** +- Application is running. +- Valid test user credentials. + +### Flow Steps: +1. Log in with valid credentials. +2. Capture initial session with permissions. +3. Reload the page. +4. Verify permissions match initial session. + +### Expected Result: +- User permissions are identical before and after refresh. +- User profile data (email, name, companyName) is preserved. + +--- + +## Test Case: `AUTH-TOKEN-E2E-003` - Clear session when cookies are removed + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @auth, @token + +**Description/Objective:** Verify that session is cleared when cookies are removed. + +**Preconditions:** +- Application is running. +- Valid test user credentials. + +### Flow Steps: +1. Log in with valid credentials. +2. Verify session is valid. +3. Clear all cookies. +4. Check session status. + +### Expected Result: +- Session returns null after cookies are cleared. +- User is effectively logged out. diff --git a/ui/tests/base-page.ts b/ui/tests/base-page.ts index 224f1b380f..2afadd4f2d 100644 --- a/ui/tests/base-page.ts +++ b/ui/tests/base-page.ts @@ -26,6 +26,14 @@ export abstract class BasePage { await this.page.goto(url); } + /** + * Navigate to URL waiting only for server response (commit). + * Use this after clearing cookies to ensure middleware runs fresh. + */ + async gotoFresh(url: string): Promise { + await this.page.goto(url, { waitUntil: "commit" }); + } + async refresh(): Promise { await this.page.reload(); } @@ -130,28 +138,28 @@ export abstract class BasePage { // 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 5f859be0e2..19dabc837f 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -1,5 +1,4 @@ import { Locator, Page, expect } from "@playwright/test"; -import { SignInPage, SignInCredentials } from "./sign-in/sign-in-page"; import { AWSProviderCredential, AWSProviderData, AWS_CREDENTIAL_OPTIONS, ProvidersPage } from "./providers/providers-page"; import { ScansPage } from "./scans/scans-page"; @@ -18,8 +17,8 @@ export const URLS = { export const TEST_CREDENTIALS = { VALID: { - email: process.env.E2E_USER || "e2e@prowler.com", - password: process.env.E2E_PASSWORD || "Thisisapassword123@", + email: process.env.E2E_ADMIN_USER || "e2e@prowler.com", + password: process.env.E2E_ADMIN_PASSWORD || "Thisisapassword123@", }, INVALID: { email: "invalid@example.com", @@ -31,143 +30,6 @@ export const TEST_CREDENTIALS = { }, } as const; -export async function goToLogin(page: Page) { - await page.goto("/sign-in"); -} - -export async function goToSignUp(page: Page) { - await page.goto("/sign-up"); -} - -export async function fillLoginForm( - page: Page, - email: string, - password: string, -) { - await page.getByLabel("Email").fill(email); - await page.getByLabel("Password").fill(password); -} - -export async function submitLoginForm(page: Page) { - await page.getByRole("button", { name: "Log in" }).click(); -} - -export async function login( - page: Page, - credentials: { email: string; password: string } = TEST_CREDENTIALS.VALID, -) { - await fillLoginForm(page, credentials.email, credentials.password); - await submitLoginForm(page); -} - -export async function verifySuccessfulLogin(page: Page) { - await expect(page).toHaveURL("/"); - await expect(page.locator("main")).toBeVisible(); - await expect( - page - .getByLabel("Breadcrumbs") - .getByRole("heading", { name: "Overview", exact: true }), - ).toBeVisible(); -} - -export async function verifyLoginError( - page: Page, - errorMessage = "Invalid email or password", -) { - // There may be multiple field-level errors with the same text; assert at least one is visible - await expect(page.getByText(errorMessage).first()).toBeVisible(); - await expect(page).toHaveURL("/sign-in"); -} - -export async function toggleSamlMode(page: Page) { - await page.getByText("Continue with SAML SSO").click(); -} - -export async function goBackFromSaml(page: Page) { - await page.getByText("Back").click(); -} - -export async function verifySamlModeActive(page: Page) { - await expect(page.getByText("Sign in with SAML SSO")).toBeVisible(); - await expect(page.getByLabel("Password")).not.toBeVisible(); - await expect(page.getByText("Back")).toBeVisible(); -} - -export async function verifyNormalModeActive(page: Page) { - await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); - await expect(page.getByLabel("Password")).toBeVisible(); -} - -export async function logout(page: Page) { - const navbar = page.locator("header"); - await navbar.waitFor({ state: "visible" }); - await navbar.getByRole("button", { name: "Sign out" }).click(); -} - -export async function verifyLogoutSuccess(page: Page) { - await expect(page).toHaveURL(/\/sign-in/); - await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); -} - -export async function verifyLoadingState(page: Page) { - const submitButton = page.getByRole("button", { name: "Log in" }); - await expect(submitButton).toHaveAttribute("aria-disabled", "true"); - await expect(page.getByText("Loading")).toBeVisible(); -} - -export async function verifyLoginFormElements(page: Page) { - await expect(page).toHaveTitle(/Prowler/); - await expect(page.locator('svg[width="300"]')).toBeVisible(); - - // Verify form elements - await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); - await expect(page.getByLabel("Email")).toBeVisible(); - await expect(page.getByLabel("Password")).toBeVisible(); - await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); - - // Verify OAuth buttons - await expect(page.getByText("Continue with Google")).toBeVisible(); - await expect(page.getByText("Continue with Github")).toBeVisible(); - await expect(page.getByText("Continue with SAML SSO")).toBeVisible(); - - // Verify navigation links - await expect(page.getByText("Need to create an account?")).toBeVisible(); - await expect(page.getByRole("link", { name: "Sign up" })).toBeVisible(); -} - -export async function waitForPageLoad(page: Page) { - await page.waitForLoadState("networkidle"); -} - -export async function verifyDashboardRoute(page: Page) { - await expect(page).toHaveURL("/"); -} - -export async function authenticateAndSaveState( - page: Page, - email: string, - password: string, - storagePath: string, -) { - if (!email || !password) { - throw new Error( - "Email and password are required for authentication and save state", - ); - } - - // Create SignInPage instance - const signInPage = new SignInPage(page); - const credentials: SignInCredentials = { email, password }; - - // Perform authentication steps using Page Object Model - await signInPage.goto(); - await signInPage.login(credentials); - await signInPage.verifySuccessfulLogin(); - - // 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 diff --git a/ui/tests/home/home-page.ts b/ui/tests/home/home-page.ts index e696e02ab5..571ace684f 100644 --- a/ui/tests/home/home-page.ts +++ b/ui/tests/home/home-page.ts @@ -2,41 +2,42 @@ import { Page, Locator, expect } from "@playwright/test"; import { BasePage } from "../base-page"; export class HomePage extends BasePage { - + // Main content elements readonly mainContent: Locator; readonly breadcrumbs: Locator; readonly overviewHeading: Locator; - + // Navigation elements readonly navigationMenu: Locator; readonly userMenu: Locator; readonly signOutButton: Locator; - + // Dashboard elements readonly dashboardCards: Locator; readonly overviewSection: Locator; - + // UI elements readonly logo: Locator; constructor(page: Page) { super(page); - + // Main content elements this.mainContent = page.locator("main"); this.breadcrumbs = page.getByRole("navigation", { name: "Breadcrumbs" }); this.overviewHeading = page.getByRole("heading", { name: "Overview", exact: true }); - + // Navigation elements this.navigationMenu = page.locator("nav"); - this.userMenu = page.getByRole("button", { name: /user menu/i }); + // Sign out is a direct button, not inside a menu + this.userMenu = page.getByRole("button", { name: "Sign out" }); this.signOutButton = page.getByRole("button", { name: "Sign out" }); - + // Dashboard elements this.dashboardCards = page.locator('[data-testid="dashboard-card"]'); this.overviewSection = page.locator('[data-testid="overview-section"]'); - + // UI elements this.logo = page.locator('svg[width="300"]'); } @@ -67,12 +68,10 @@ export class HomePage extends BasePage { await this.overviewHeading.click(); } - async openUserMenu(): Promise { - await this.userMenu.click(); - } - async signOut(): Promise { - await this.openUserMenu(); + // Wait for navbar to be visible before clicking sign out + const navbar = this.page.locator("header"); + await navbar.waitFor({ state: "visible" }); await this.signOutButton.click(); } diff --git a/ui/tests/invitations/invitations.spec.ts b/ui/tests/invitations/invitations.spec.ts index 72a3d2e390..bcaf0adb8b 100644 --- a/ui/tests/invitations/invitations.spec.ts +++ b/ui/tests/invitations/invitations.spec.ts @@ -2,7 +2,7 @@ import { test } from "@playwright/test"; import { InvitationsPage } from "./invitations-page"; import { makeSuffix } from "../helpers"; import { SignUpPage } from "../sign-up/sign-up-page"; -import { SignInPage } from "../sign-in/sign-in-page"; +import { SignInPage } from "../sign-in-base/sign-in-base-page"; import { UserProfilePage } from "../profile/profile-page"; test.describe("New user invitation", () => { diff --git a/ui/tests/profile/profile-page.ts b/ui/tests/profile/profile-page.ts index 4bd5854113..06ec467933 100644 --- a/ui/tests/profile/profile-page.ts +++ b/ui/tests/profile/profile-page.ts @@ -15,11 +15,18 @@ export class UserProfilePage extends BasePage { } async goto(): Promise { - // Navigate to the user profile page - await super.goto("/profile"); } + async verifyPageLoaded(): Promise { + await expect(this.page).toHaveURL("/profile"); + await expect(this.pageHeadingUserProfile).toBeVisible(); + } + + async verifyOnProfilePage(): Promise { + await expect(this.page).toHaveURL("/profile"); + } + async verifyOrganizationId(organizationId: string): Promise { // Verify the organization ID is visible diff --git a/ui/tests/setups/admin.auth.setup.ts b/ui/tests/setups/admin.auth.setup.ts index 64375aff86..86094cc1cf 100644 --- a/ui/tests/setups/admin.auth.setup.ts +++ b/ui/tests/setups/admin.auth.setup.ts @@ -1,5 +1,5 @@ import { test as authAdminSetup } from '@playwright/test'; -import { authenticateAndSaveState } from '@/tests/helpers'; +import { SignInPage } from '../sign-in-base/sign-in-base-page'; const adminUserFile = 'playwright/.auth/admin_user.json'; @@ -12,5 +12,6 @@ authAdminSetup('authenticate as admin e2e user', async ({ page }) => { throw new Error('E2E_ADMIN_USER and E2E_ADMIN_PASSWORD environment variables are required'); } - await authenticateAndSaveState(page, adminEmail, adminPassword, adminUserFile); + const signInPage = new SignInPage(page); + await signInPage.authenticateAndSaveState({ email: adminEmail, password: adminPassword }, adminUserFile); }); \ No newline at end of file diff --git a/ui/tests/setups/invite-and-manage-users.auth.setup.ts b/ui/tests/setups/invite-and-manage-users.auth.setup.ts index e11eecdaa3..c9dc6cf273 100644 --- a/ui/tests/setups/invite-and-manage-users.auth.setup.ts +++ b/ui/tests/setups/invite-and-manage-users.auth.setup.ts @@ -1,5 +1,5 @@ import { test as authInviteAndManageUsersSetup } from '@playwright/test'; -import { authenticateAndSaveState } from '@/tests/helpers'; +import { SignInPage } from '../sign-in-base/sign-in-base-page'; const inviteAndManageUsersUserFile = 'playwright/.auth/invite_and_manage_users_user.json'; @@ -11,5 +11,6 @@ authInviteAndManageUsersSetup('authenticate as invite and manage users e2e user' throw new Error('E2E_INVITE_AND_MANAGE_USERS_USER and E2E_INVITE_AND_MANAGE_USERS_PASSWORD environment variables are required'); } - await authenticateAndSaveState(page, inviteAndManageUsersEmail, inviteAndManageUsersPassword, inviteAndManageUsersUserFile); + const signInPage = new SignInPage(page); + await signInPage.authenticateAndSaveState({ email: inviteAndManageUsersEmail, password: inviteAndManageUsersPassword }, inviteAndManageUsersUserFile); }); diff --git a/ui/tests/setups/manage-account.auth.setup.ts b/ui/tests/setups/manage-account.auth.setup.ts index 4fe5b7b5a2..18e394b753 100644 --- a/ui/tests/setups/manage-account.auth.setup.ts +++ b/ui/tests/setups/manage-account.auth.setup.ts @@ -1,5 +1,5 @@ import { test as authManageAccountSetup } from '@playwright/test'; -import { authenticateAndSaveState } from '@/tests/helpers'; +import { SignInPage } from '../sign-in-base/sign-in-base-page'; const manageAccountUserFile = 'playwright/.auth/manage_account_user.json'; @@ -11,5 +11,6 @@ authManageAccountSetup('authenticate as manage account e2e user', async ({ page throw new Error('E2E_MANAGE_ACCOUNT_USER and E2E_MANAGE_ACCOUNT_PASSWORD environment variables are required'); } - await authenticateAndSaveState(page, accountEmail, accountPassword, manageAccountUserFile); + const signInPage = new SignInPage(page); + await signInPage.authenticateAndSaveState({ email: accountEmail, password: accountPassword }, manageAccountUserFile); }); diff --git a/ui/tests/setups/manage-cloud-providers.auth.setup.ts b/ui/tests/setups/manage-cloud-providers.auth.setup.ts index 0213ab0cb2..3ddaeeca70 100644 --- a/ui/tests/setups/manage-cloud-providers.auth.setup.ts +++ b/ui/tests/setups/manage-cloud-providers.auth.setup.ts @@ -1,5 +1,5 @@ import { test as authManageCloudProvidersSetup } from '@playwright/test'; -import { authenticateAndSaveState } from '@/tests/helpers'; +import { SignInPage } from '../sign-in-base/sign-in-base-page'; const manageCloudProvidersUserFile = 'playwright/.auth/manage_cloud_providers_user.json'; @@ -11,5 +11,6 @@ authManageCloudProvidersSetup('authenticate as manage cloud providers e2e user', throw new Error('E2E_MANAGE_CLOUD_PROVIDERS_USER and E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD environment variables are required'); } - await authenticateAndSaveState(page, cloudProvidersEmail, cloudProvidersPassword, manageCloudProvidersUserFile); + const signInPage = new SignInPage(page); + await signInPage.authenticateAndSaveState({ email: cloudProvidersEmail, password: cloudProvidersPassword }, manageCloudProvidersUserFile); }); diff --git a/ui/tests/setups/manage-integrations.auth.setup.ts b/ui/tests/setups/manage-integrations.auth.setup.ts index fb98e4a157..ae53df5458 100644 --- a/ui/tests/setups/manage-integrations.auth.setup.ts +++ b/ui/tests/setups/manage-integrations.auth.setup.ts @@ -1,5 +1,5 @@ import { test as authManageIntegrationsSetup } from '@playwright/test'; -import { authenticateAndSaveState } from '@/tests/helpers'; +import { SignInPage } from '../sign-in-base/sign-in-base-page'; const manageIntegrationsUserFile = 'playwright/.auth/manage_integrations_user.json'; @@ -11,5 +11,6 @@ authManageIntegrationsSetup('authenticate as integrations e2e user', async ({ p throw new Error('E2E_MANAGE_INTEGRATIONS_USER and E2E_MANAGE_INTEGRATIONS_PASSWORD environment variables are required'); } - await authenticateAndSaveState(page, integrationsEmail, integrationsPassword, manageIntegrationsUserFile); + const signInPage = new SignInPage(page); + await signInPage.authenticateAndSaveState({ email: integrationsEmail, password: integrationsPassword }, manageIntegrationsUserFile); }); diff --git a/ui/tests/setups/manage-scans.auth.setup.ts b/ui/tests/setups/manage-scans.auth.setup.ts index 7a8f7e95e2..84d41a657d 100644 --- a/ui/tests/setups/manage-scans.auth.setup.ts +++ b/ui/tests/setups/manage-scans.auth.setup.ts @@ -1,5 +1,5 @@ import { test as authManageScansSetup } from '@playwright/test'; -import { authenticateAndSaveState } from '@/tests/helpers'; +import { SignInPage } from '../sign-in-base/sign-in-base-page'; const manageScansUserFile = 'playwright/.auth/manage_scans_user.json'; @@ -11,5 +11,6 @@ authManageScansSetup('authenticate as scans e2e user', async ({ page }) => { throw new Error('E2E_MANAGE_SCANS_USER and E2E_MANAGE_SCANS_PASSWORD environment variables are required'); } - await authenticateAndSaveState(page, scansEmail, scansPassword, manageScansUserFile); + const signInPage = new SignInPage(page); + await signInPage.authenticateAndSaveState({ email: scansEmail, password: scansPassword }, manageScansUserFile); }); diff --git a/ui/tests/setups/unlimited-visibility.auth.setup.ts b/ui/tests/setups/unlimited-visibility.auth.setup.ts index 533158ec70..0c5d0fd1ce 100644 --- a/ui/tests/setups/unlimited-visibility.auth.setup.ts +++ b/ui/tests/setups/unlimited-visibility.auth.setup.ts @@ -1,5 +1,5 @@ import { test as authUnlimitedVisibilitySetup } from '@playwright/test'; -import { authenticateAndSaveState } from '@/tests/helpers'; +import { SignInPage } from '../sign-in-base/sign-in-base-page'; const unlimitedVisibilityUserFile = 'playwright/.auth/unlimited_visibility_user.json'; @@ -11,5 +11,6 @@ authUnlimitedVisibilitySetup('authenticate as unlimited visibility e2e user', a throw new Error('E2E_UNLIMITED_VISIBILITY_USER and E2E_UNLIMITED_VISIBILITY_PASSWORD environment variables are required'); } - await authenticateAndSaveState(page, unlimitedVisibilityEmail, unlimitedVisibilityPassword, unlimitedVisibilityUserFile); + const signInPage = new SignInPage(page); + await signInPage.authenticateAndSaveState({ email: unlimitedVisibilityEmail, password: unlimitedVisibilityPassword }, unlimitedVisibilityUserFile); }); diff --git a/ui/tests/sign-in/sign-in-page.ts b/ui/tests/sign-in-base/sign-in-base-page.ts similarity index 56% rename from ui/tests/sign-in/sign-in-page.ts rename to ui/tests/sign-in-base/sign-in-base-page.ts index a694359422..d94fd57eb3 100644 --- a/ui/tests/sign-in/sign-in-page.ts +++ b/ui/tests/sign-in-base/sign-in-base-page.ts @@ -1,4 +1,5 @@ -import { Page, Locator, expect } from "@playwright/test"; +import { expect, Locator, Page } from "@playwright/test"; + import { BasePage } from "../base-page"; import { HomePage } from "../home/home-page"; @@ -14,28 +15,29 @@ export interface SocialAuthConfig { export class SignInPage extends BasePage { readonly homePage: HomePage; - + // Form elements readonly emailInput: Locator; readonly passwordInput: Locator; readonly loginButton: Locator; readonly form: Locator; - + // Social authentication buttons readonly googleButton: Locator; readonly githubButton: Locator; readonly samlButton: Locator; - + // Navigation elements readonly signUpLink: Locator; readonly backButton: Locator; - + // UI elements readonly logo: Locator; - + readonly pageTitle: Locator; + // Error messages readonly errorMessages: Locator; - + // SAML specific elements readonly samlModeTitle: Locator; readonly samlEmailInput: Locator; @@ -43,30 +45,42 @@ export class SignInPage extends BasePage { constructor(page: Page) { super(page); this.homePage = new HomePage(page); - + // Form elements this.emailInput = page.getByRole("textbox", { name: "Email" }); this.passwordInput = page.getByRole("textbox", { name: "Password" }); this.loginButton = page.getByRole("button", { name: "Log in" }); this.form = page.locator("form"); - + // Social authentication buttons - this.googleButton = page.getByRole("button", { name: "Continue with Google" }); - this.githubButton = page.getByRole("button", { name: "Continue with Github" }); - this.samlButton = page.getByRole("button", { name: "Continue with SAML SSO" }); - + this.googleButton = page.getByRole("button", { + name: "Continue with Google", + }); + this.githubButton = page.getByRole("button", { + name: "Continue with Github", + }); + this.samlButton = page.getByRole("button", { + name: "Continue with SAML SSO", + }); + // Navigation elements this.signUpLink = page.getByRole("link", { name: "Sign up" }); this.backButton = page.getByRole("button", { name: "Back" }); - - // UI elements + + // UI elements - title is a

element, not a heading this.logo = page.locator('svg[width="300"]'); - - // Error messages - this.errorMessages = page.locator('[role="alert"], .error-message, [data-testid="error"]'); - - // SAML specific elements - this.samlModeTitle = page.getByRole("heading", { name: "Sign in with SAML SSO" }); + // Use text matching with exact=true to avoid matching other elements + this.pageTitle = page.getByText("Sign in", { exact: true }); + + // Error messages - form validation errors appear as

with specific classes + this.errorMessages = page.locator( + "p.text-destructive, p.text-sm.text-destructive", + ); + + // SAML specific elements - use text matching + this.samlModeTitle = page.getByText("Sign in with SAML SSO", { + exact: true, + }); this.samlEmailInput = page.getByRole("textbox", { name: "Email" }); } @@ -75,6 +89,14 @@ export class SignInPage extends BasePage { await super.goto("/sign-in"); } + /** + * Navigate to sign-in page with an error query parameter + * Used for testing error message display (e.g., session expiry) + */ + async gotoWithError(errorType: string): Promise { + await this.page.goto(`/sign-in?error=${errorType}`); + } + // Form interaction methods async fillEmail(email: string): Promise { await this.emailInput.fill(email); @@ -137,7 +159,8 @@ export class SignInPage extends BasePage { async verifyPageLoaded(): Promise { await expect(this.page).toHaveTitle(/Prowler/); await expect(this.logo).toBeVisible(); - await expect(this.page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible(); + await expect(this.pageTitle).toBeVisible(); + await expect(this.pageTitle).toHaveText("Sign in"); } async verifyFormElements(): Promise { @@ -157,7 +180,9 @@ export class SignInPage extends BasePage { } async verifyNavigationLinks(): Promise { - await expect(this.page.getByRole('link', { name: /Need to create an account\?/i })).toBeVisible(); + await expect( + this.page.getByText("Need to create an account?"), + ).toBeVisible(); await expect(this.signUpLink).toBeVisible(); } @@ -165,8 +190,21 @@ export class SignInPage extends BasePage { await this.homePage.verifyPageLoaded(); } - async verifyLoginError(errorMessage: string = "Invalid email or password"): Promise { - await expect(this.page.getByRole("alert", { name: errorMessage })).toBeVisible(); + /** + * Complete login flow: navigates to sign-in, logs in, and verifies success. + * Use this when you need to authenticate from scratch in a test. + */ + async loginAndVerify(credentials: SignInCredentials): Promise { + await this.goto(); + await this.login(credentials); + await this.verifySuccessfulLogin(); + } + + async verifyLoginError( + errorMessage: string = "Invalid email or password", + ): Promise { + // Error messages appear as

elements with destructive styling + await expect(this.page.getByText(errorMessage).first()).toBeVisible(); await expect(this.page).toHaveURL("/sign-in"); } @@ -177,22 +215,24 @@ export class SignInPage extends BasePage { } async verifyNormalModeActive(): Promise { - await expect(this.page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible(); + await expect(this.pageTitle).toHaveText("Sign in"); await expect(this.passwordInput).toBeVisible(); } async verifyLoadingState(): Promise { + // Check that button shows loading text or is disabled await expect(this.loginButton).toHaveAttribute("aria-disabled", "true"); - await super.verifyLoadingState(); } async verifyFormValidation(): Promise { - // Check for common validation messages - const emailError = this.page.getByRole("alert", { name: "Please enter a valid email address." }); - const passwordError = this.page.getByRole("alert", { name: "Password is required." }); - + // Check for common validation messages - they appear as

elements + const emailError = this.page.getByText( + "Please enter a valid email address.", + ); + const passwordError = this.page.getByText("Password is required."); + // At least one validation error should be visible - await expect(emailError.or(passwordError)).toBeVisible(); + await expect(emailError.or(passwordError).first()).toBeVisible(); } // Accessibility methods @@ -211,9 +251,15 @@ export class SignInPage extends BasePage { } async verifyAriaLabels(): Promise { - await expect(this.page.getByRole("textbox", { name: "Email" })).toBeVisible(); - await expect(this.page.getByRole("textbox", { name: "Password" })).toBeVisible(); - await expect(this.page.getByRole("button", { name: "Log in" })).toBeVisible(); + await expect( + this.page.getByRole("textbox", { name: "Email" }), + ).toBeVisible(); + await expect( + this.page.getByRole("textbox", { name: "Password" }), + ).toBeVisible(); + await expect( + this.page.getByRole("button", { name: "Log in" }), + ).toBeVisible(); } // Utility methods @@ -228,16 +274,62 @@ export class SignInPage extends BasePage { return emailValue.length > 0 && passwordValue.length > 0; } - // Browser interaction methods - // Session management methods async logout(): Promise { await this.homePage.signOut(); } - async verifyLogoutSuccess(): Promise { + /** + * Verifies we are on the sign-in page (URL + title visible) + * Use this when redirected to sign-in from protected routes + */ + async verifyOnSignInPage(): Promise { + await expect(this.page).toHaveURL(/\/sign-in/); + await expect(this.pageTitle).toBeVisible(); + } + + /** + * Verifies we stayed on the sign-in page (exact URL match) + * Use after form validation errors + */ + async verifyStaysOnSignInPage(): Promise { await expect(this.page).toHaveURL("/sign-in"); - await expect(this.page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible(); + } + + /** + * Verifies we are NOT on the sign-in page + * Use after successful login to confirm redirect + */ + async verifyNotOnSignInPage(): Promise { + await expect(this.page).not.toHaveURL(/\/sign-in/); + } + + /** + * Verifies redirect to sign-in includes a callbackUrl parameter + */ + async verifyRedirectWithCallback(expectedCallback: string): Promise { + await expect(this.page).toHaveURL(/\/sign-in\?.*callbackUrl=/); + const url = new URL(this.page.url()); + const callbackUrl = url.searchParams.get("callbackUrl"); + expect(callbackUrl).toBe(expectedCallback); + } + + /** + * Wait for toast notification to appear (used for error messages) + */ + async waitForToast(): Promise<{ isVisible: boolean; text: string | null }> { + const toast = this.page.locator('[role="status"], [role="alert"]').first(); + try { + await toast.waitFor({ state: "visible", timeout: 2000 }); + const text = await toast.textContent(); + return { isVisible: true, text }; + } catch { + return { isVisible: false, text: null }; + } + } + + async verifyLogoutSuccess(): Promise { + await this.verifyOnSignInPage(); } // Advanced interaction methods @@ -245,7 +337,7 @@ export class SignInPage extends BasePage { // Fill email first and check for validation await this.fillEmail(credentials.email); await this.page.keyboard.press("Tab"); // Trigger validation - + // Fill password await this.fillPassword(credentials.password); } @@ -260,10 +352,11 @@ export class SignInPage extends BasePage { // Error handling methods async handleSamlError(): Promise { - const samlError = this.page.getByRole("alert", { name: "SAML Authentication Error" }); + const samlError = this.page.getByRole("alert", { + name: "SAML Authentication Error", + }); if (await samlError.isVisible()) { - // Handle SAML error if present - console.log("SAML authentication error detected"); + // SAML error detected - test may need to handle this case } } @@ -278,4 +371,29 @@ export class SignInPage extends BasePage { async waitForRedirect(expectedUrl: string): Promise { await this.page.waitForURL(expectedUrl); } + + // =========================================== + // Authentication Setup Methods + // =========================================== + + /** + * Authenticate and save the browser state to a file. + * Used by auth setup projects to create reusable authenticated sessions. + * + * @param credentials - User credentials (email and password) + * @param storagePath - Path to save the authentication state file + */ + async authenticateAndSaveState( + credentials: SignInCredentials, + storagePath: string, + ): Promise { + if (!credentials.email || !credentials.password) { + throw new Error( + "Email and password are required for authentication and save state", + ); + } + + await this.loginAndVerify(credentials); + await this.page.context().storageState({ path: storagePath }); + } } diff --git a/ui/tests/sign-in-base/sign-in-base.md b/ui/tests/sign-in-base/sign-in-base.md new file mode 100644 index 0000000000..8298b375fb --- /dev/null +++ b/ui/tests/sign-in-base/sign-in-base.md @@ -0,0 +1,446 @@ +### E2E Tests: User Sign-In + +**Suite ID:** `SIGN-IN-BASE-E2E` +**Feature:** User sign-in form and navigation. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-001` - Display login form elements + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify that all login form elements are displayed correctly. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Verify page is loaded. +3. Verify form elements (email, password, login button). +4. Verify social buttons (Google, GitHub). +5. Verify navigation links. + +### Expected Result: +- All form elements are visible and properly labeled. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-002` - Successful login with valid credentials + +**Priority:** `critical` + +**Tags:** +- type: @e2e, @critical +- feature: @sign-in-base + +**Description/Objective:** Verify that a user can successfully log in with valid credentials. + +**Preconditions:** +- Application is running. +- Valid test user credentials are configured via `ADMIN_USER` and `ADMIN_PASSWORD` environment variables. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Enter valid email and password. +3. Click the login button. +4. Verify successful redirect to home page. + +### Expected Result: +- User is authenticated and redirected to the home page. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-003` - Show error with invalid credentials + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify that an error message is shown when invalid credentials are provided. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Enter invalid email and password. +3. Click the login button. +4. Verify error message is displayed. + +### Expected Result: +- Error message "Invalid email or password" is displayed. +- User remains on the sign-in page. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-004` - Handle empty form submission + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify form validation when submitting an empty form. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Click the login button without filling any fields. +3. Verify validation errors are displayed. + +### Expected Result: +- Form validation errors are shown. +- User remains on the sign-in page. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-005` - Validate email format + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify that invalid email formats are rejected. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Enter an invalid email format. +3. Submit the form. +4. Verify validation error is displayed. + +### Expected Result: +- Email format validation error is shown. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-006` - Require password when email is filled + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify that password is required when email is provided. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Fill only the email field. +3. Submit the form. +4. Verify password required error is displayed. + +### Expected Result: +- "Password is required" error is shown. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-007` - Toggle SAML SSO mode + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify SAML SSO mode can be toggled on and off. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Click "Continue with SAML SSO" button. +3. Verify SAML mode is active (password field hidden). +4. Click back button. +5. Verify normal mode is restored. + +### Expected Result: +- SAML mode toggles correctly. +- Password field visibility changes accordingly. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-008` - Show loading state during form submission + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify loading state is shown during form submission. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Fill valid credentials. +3. Submit the form. +4. Verify loading state on button. + +### Expected Result: +- Login button shows loading state (disabled with aria-disabled). + +--- + +## Test Case: `SIGN-IN-BASE-E2E-009` - Handle SAML authentication flow + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify SAML authentication flow initiation. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Toggle SAML mode. +3. Enter SAML email. +4. Submit the form. + +### Expected Result: +- SAML flow is initiated (would redirect to IdP in real scenario). + +--- + +## Test Case: `SIGN-IN-BASE-E2E-010` - Maintain session after browser refresh + +**Priority:** `critical` + +**Tags:** +- type: @e2e, @critical +- feature: @sign-in-base + +**Description/Objective:** Verify that user session persists after page refresh. + +**Preconditions:** +- Application is running. +- Valid test user credentials. + +### Flow Steps: +1. Log in with valid credentials. +2. Verify successful login. +3. Refresh the page. +4. Verify user is still logged in. + +### Expected Result: +- Session persists after refresh. +- User remains on the authenticated page. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-011` - Redirect to login for protected routes + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify unauthenticated users are redirected to login when accessing protected routes. + +**Preconditions:** +- Application is running. +- No active session. + +### Flow Steps: +1. Navigate directly to a protected route (e.g., /providers). +2. Verify redirect to sign-in page. + +### Expected Result: +- User is redirected to /sign-in. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-012` - Logout successfully + +**Priority:** `critical` + +**Tags:** +- type: @e2e, @critical +- feature: @sign-in-base + +**Description/Objective:** Verify user can log out successfully. + +**Preconditions:** +- Application is running. +- User is logged in. + +### Flow Steps: +1. Log in with valid credentials. +2. Click logout/sign out. +3. Verify redirect to sign-in page. +4. Attempt to access protected route. +5. Verify redirect to sign-in. + +### Expected Result: +- User is logged out. +- Session is invalidated. +- Protected routes are no longer accessible. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-013` - Handle session timeout gracefully + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify session isolation between browser contexts. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Create authenticated context and log in. +2. Verify session exists. +3. Create new unauthenticated context. +4. Verify new context has no session. +5. Verify new context is redirected to sign-in. + +### Expected Result: +- Sessions are isolated between contexts. +- Unauthenticated context cannot access protected routes. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-014` - Navigate to sign up page + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify navigation from sign-in to sign-up page. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Click the "Sign up" link. +3. Verify redirect to sign-up page. + +### Expected Result: +- User is navigated to /sign-up. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-015` - Navigate from sign up back to sign in + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify navigation from sign-up back to sign-in page. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign up page. +2. Click the login link. +3. Verify redirect to sign-in page. + +### Expected Result: +- User is navigated to /sign-in. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-016` - Handle browser back button correctly + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base + +**Description/Objective:** Verify browser back button navigation works correctly. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Navigate to the Sign up page. +3. Click browser back button. +4. Verify return to sign-in page. + +### Expected Result: +- Browser history navigation works correctly. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-017` - Keyboard navigation + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base, @accessibility + +**Description/Objective:** Verify form is navigable with keyboard. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Use Tab key to navigate through form elements. +3. Verify focus moves correctly through elements. + +### Expected Result: +- All interactive elements are reachable via keyboard. +- Focus order is logical. + +--- + +## Test Case: `SIGN-IN-BASE-E2E-018` - Proper ARIA labels + +**Priority:** `normal` + +**Tags:** +- type: @e2e +- feature: @sign-in-base, @accessibility + +**Description/Objective:** Verify form elements have proper ARIA labels. + +**Preconditions:** +- Application is running. + +### Flow Steps: +1. Navigate to the Sign in page. +2. Verify ARIA labels on form elements. + +### Expected Result: +- Email input has proper label. +- Password input has proper label. +- Login button has proper label. diff --git a/ui/tests/sign-in-base/sign-in-base.spec.ts b/ui/tests/sign-in-base/sign-in-base.spec.ts new file mode 100644 index 0000000000..30d60968e5 --- /dev/null +++ b/ui/tests/sign-in-base/sign-in-base.spec.ts @@ -0,0 +1,262 @@ +import { expect, test } from "@playwright/test"; + +import { ERROR_MESSAGES, getSession, TEST_CREDENTIALS, URLS } from "../helpers"; +import { HomePage } from "../home/home-page"; +import { SignUpPage } from "../sign-up/sign-up-page"; +import { SignInPage } from "./sign-in-base-page"; + +test.describe("Login Flow", () => { + let signInPage: SignInPage; + + test.beforeEach(async ({ page }) => { + signInPage = new SignInPage(page); + await signInPage.goto(); + }); + + test( + "should display login form elements", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-001"] }, + async () => { + await signInPage.verifyPageLoaded(); + await signInPage.verifyFormElements(); + await signInPage.verifySocialButtons({ + googleEnabled: true, + githubEnabled: true, + }); + await signInPage.verifyNavigationLinks(); + }, + ); + + test( + "should successfully login with valid credentials", + { tag: ["@critical", "@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-002"] }, + async () => { + await signInPage.login(TEST_CREDENTIALS.VALID); + await signInPage.verifySuccessfulLogin(); + }, + ); + + test( + "should show error message with invalid credentials", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-003"] }, + async () => { + await signInPage.login(TEST_CREDENTIALS.INVALID); + await signInPage.verifyLoginError(ERROR_MESSAGES.INVALID_CREDENTIALS); + }, + ); + + test( + "should handle empty form submission", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-004"] }, + async () => { + await signInPage.submitForm(); + await signInPage.verifyFormValidation(); + await signInPage.verifyStaysOnSignInPage(); + }, + ); + + test( + "should validate email format", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-005"] }, + async () => { + await signInPage.login(TEST_CREDENTIALS.INVALID_EMAIL_FORMAT); + await signInPage.verifyFormValidation(); + await signInPage.verifyStaysOnSignInPage(); + }, + ); + + test( + "should require password when email is filled", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-006"] }, + async () => { + await signInPage.fillEmail(TEST_CREDENTIALS.VALID.email); + await signInPage.submitForm(); + await expect( + signInPage.page.getByText(ERROR_MESSAGES.PASSWORD_REQUIRED), + ).toBeVisible(); + await signInPage.verifyStaysOnSignInPage(); + }, + ); + + test( + "should toggle SAML SSO mode", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-007"] }, + async () => { + await signInPage.toggleSamlMode(); + await signInPage.verifySamlModeActive(); + await signInPage.goBackFromSaml(); + await signInPage.verifyNormalModeActive(); + }, + ); + + test( + "should show loading state during form submission", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-008"] }, + async () => { + await signInPage.fillCredentials(TEST_CREDENTIALS.VALID); + await signInPage.submitForm(); + await signInPage.verifyLoadingState(); + }, + ); + + test( + "should handle SAML authentication flow", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-009"] }, + async () => { + const samlEmail = "user@saml-domain.com"; + await signInPage.toggleSamlMode(); + await signInPage.fillSamlEmail(samlEmail); + await signInPage.submitSamlForm(); + // Note: In a real scenario, this would redirect to IdP + }, + ); +}); + +test.describe("Session Persistence", () => { + test( + "should maintain session after browser refresh", + { tag: ["@critical", "@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-010"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + const homePage = new HomePage(page); + + await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID); + + await page.reload(); + await homePage.verifyPageLoaded(); + await signInPage.verifyNotOnSignInPage(); + }, + ); + + test( + "should redirect to login when accessing protected route without session", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-011"] }, + async ({ page }) => { + const homePage = new HomePage(page); + const signInPage = new SignInPage(page); + + await homePage.goto(); + await signInPage.verifyOnSignInPage(); + }, + ); + + test( + "should logout successfully", + { tag: ["@critical", "@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-012"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + const homePage = new HomePage(page); + + await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID); + + await homePage.signOut(); + await signInPage.verifyLogoutSuccess(); + + await homePage.goto(); + await signInPage.verifyOnSignInPage(); + }, + ); + + test( + "should handle session timeout gracefully", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-013"] }, + async ({ browser }) => { + const authContext = await browser.newContext(); + const authPage = await authContext.newPage(); + + const signInPage = new SignInPage(authPage); + await signInPage.loginAndVerify(TEST_CREDENTIALS.VALID); + + const authSession = await getSession(authPage); + expect(authSession).toBeTruthy(); + expect(authSession.user).toBeTruthy(); + + const unauthContext = await browser.newContext(); + const unauthPage = await unauthContext.newPage(); + const unauthSignInPage = new SignInPage(unauthPage); + + await unauthPage.goto(URLS.PROFILE); + await unauthSignInPage.verifyOnSignInPage(); + + const unauthSession = await getSession(unauthPage); + expect(unauthSession).toBeNull(); + + await authPage.close(); + await authContext.close(); + await unauthPage.close(); + await unauthContext.close(); + }, + ); +}); + +test.describe("Navigation", () => { + test( + "should navigate to sign up page", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-014"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + const signUpPage = new SignUpPage(page); + + await signInPage.goto(); + await signInPage.goToSignUp(); + await signUpPage.verifyOnSignUpPage(); + }, + ); + + test( + "should navigate from sign up back to sign in", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-015"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + const signUpPage = new SignUpPage(page); + + await signUpPage.goto(); + await signUpPage.loginLink.click(); + await signInPage.verifyOnSignInPage(); + }, + ); + + test( + "should handle browser back button correctly", + { tag: ["@e2e", "@sign-in-base", "@SIGN-IN-BASE-E2E-016"] }, + async ({ page }) => { + const signInPage = new SignInPage(page); + const signUpPage = new SignUpPage(page); + + await signInPage.goto(); + await signInPage.goToSignUp(); + await signUpPage.verifyOnSignUpPage(); + await page.goBack(); + await signInPage.verifyOnSignInPage(); + }, + ); +}); + +test.describe("Accessibility", () => { + let signInPage: SignInPage; + + test.beforeEach(async ({ page }) => { + signInPage = new SignInPage(page); + await signInPage.goto(); + }); + + test( + "should be navigable with keyboard", + { + tag: ["@e2e", "@sign-in-base", "@accessibility", "@SIGN-IN-BASE-E2E-017"], + }, + async () => { + await signInPage.verifyKeyboardNavigation(); + }, + ); + + test( + "should have proper ARIA labels", + { + tag: ["@e2e", "@sign-in-base", "@accessibility", "@SIGN-IN-BASE-E2E-018"], + }, + async () => { + await signInPage.verifyAriaLabels(); + }, + ); +}); diff --git a/ui/tests/sign-in/auth-login.spec.ts b/ui/tests/sign-in/auth-login.spec.ts deleted file mode 100644 index 3f81d5b2b1..0000000000 --- a/ui/tests/sign-in/auth-login.spec.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { - goToLogin, - goToSignUp, - fillLoginForm, - submitLoginForm, - login, - verifySuccessfulLogin, - verifyLoginError, - verifyLoginFormElements, - verifyDashboardRoute, - toggleSamlMode, - verifySamlModeActive, - goBackFromSaml, - verifyNormalModeActive, - logout, - verifyLogoutSuccess, - waitForPageLoad, - TEST_CREDENTIALS, - ERROR_MESSAGES, - URLS, - verifyLoadingState, -} from "../helpers"; - -test.describe("Login Flow", () => { - test.beforeEach(async ({ page }) => { - await goToLogin(page); - }); - - test("should display login form elements", async ({ page }) => { - await verifyLoginFormElements(page); - }); - - test("should successfully login with valid credentials", async ({ page }) => { - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - await verifyDashboardRoute(page); - }); - - test("should show error message with invalid credentials", async ({ - page, - }) => { - // Attempt login with invalid credentials - await login(page, TEST_CREDENTIALS.INVALID); - await verifyLoginError(page, ERROR_MESSAGES.INVALID_CREDENTIALS); - }); - - test("should handle empty form submission", async ({ page }) => { - // Submit empty form - await submitLoginForm(page); - - // Should show both email and password validation errors - await verifyLoginError(page, ERROR_MESSAGES.INVALID_EMAIL); - await verifyLoginError(page, ERROR_MESSAGES.PASSWORD_REQUIRED); - - // Verify we're still on login page - await expect(page).toHaveURL(URLS.LOGIN); - }); - - test("should validate email format", async ({ page }) => { - // Attempt login with invalid email format - await login(page, TEST_CREDENTIALS.INVALID_EMAIL_FORMAT); - // Verify field-level email validation message - await verifyLoginError(page, ERROR_MESSAGES.INVALID_EMAIL); - // Verify we're still on login page - await expect(page).toHaveURL(URLS.LOGIN); - }); - - test("should require password when email is filled", async ({ page }) => { - // Fill only email, leave password empty - await page.getByLabel("Email").fill(TEST_CREDENTIALS.VALID.email); - await submitLoginForm(page); - - // Should show password required error - await verifyLoginError(page, ERROR_MESSAGES.PASSWORD_REQUIRED); - - // Verify we're still on login page - await expect(page).toHaveURL(URLS.LOGIN); - }); - - test("should toggle SAML SSO mode", async ({ page }) => { - // Toggle to SAML mode - await toggleSamlMode(page); - await verifySamlModeActive(page); - // Toggle back to normal mode - await goBackFromSaml(page); - await verifyNormalModeActive(page); - }); - - test("should show loading state during form submission", async ({ page }) => { - // Fill valid credentials - await fillLoginForm( - page, - TEST_CREDENTIALS.VALID.email, - TEST_CREDENTIALS.VALID.password, - ); - // Submit form and verify loading state - await submitLoginForm(page); - // Verify loading state - await verifyLoadingState(page); - }); - - test("should handle SAML authentication flow", async ({ page }) => { - // Enter email for SAML - const samlEmail = "user@saml-domain.com"; - // Toggle to SAML mode - await toggleSamlMode(page); - // Fill email (password should be hidden) - await page.getByLabel("Email").fill(samlEmail); - // Submit should trigger SAML redirect (we can't test the actual SAML flow in E2E) - // but we can verify the form submission - await submitLoginForm(page); - - // Note: In a real scenario, this would redirect to IdP - // For testing, we just verify the form was submitted - }); -}); - -test.describe("Session Persistence", () => { - test("should maintain session after browser refresh", async ({ page }) => { - // Login first - await goToLogin(page); - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - // Refresh the page - await page.reload(); - await waitForPageLoad(page); - // Verify session is maintained - await expect(page).toHaveURL(URLS.DASHBOARD); - await verifyDashboardRoute(page); - // Verify user is not redirected back to login - await expect(page).not.toHaveURL(URLS.LOGIN); - }); - - test("should redirect to login when accessing protected route without session", async ({ - page, - }) => { - // Try to access protected route without login - await page.goto(URLS.DASHBOARD); - // Should be redirected to login page (may include callbackUrl) - await expect(page).toHaveURL(/\/sign-in/); - await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); - }); - - test("should logout successfully", async ({ page }) => { - // Login first - await goToLogin(page); - await login(page, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(page); - - // Logout - await logout(page); - await verifyLogoutSuccess(page); - - // Verify cannot access protected route after logout - await page.goto(URLS.DASHBOARD); - await expect(page).toHaveURL(/\/sign-in/); - }); - - test("should handle session timeout gracefully", async ({ browser }) => { - // Test approach: Verify that a new browser context without auth cookies - // gets redirected to login when accessing protected routes - - // First, login in one context to verify auth works - const authContext = await browser.newContext(); - const authPage = await authContext.newPage(); - - await goToLogin(authPage); - await login(authPage, TEST_CREDENTIALS.VALID); - await verifySuccessfulLogin(authPage); - - // Verify session exists in authenticated context - const authResponse = await authPage.request.get("/api/auth/session"); - const authSession = await authResponse.json(); - expect(authSession).toBeTruthy(); - expect(authSession.user).toBeTruthy(); - - // Now create a completely separate context without any auth - const unauthContext = await browser.newContext(); - const unauthPage = await unauthContext.newPage(); - - // Try to access protected route in unauthenticated context - await unauthPage.goto(URLS.PROFILE, { - waitUntil: "networkidle", - }); - - // Should be redirected to login since this context has no auth (may include callbackUrl) - await expect(unauthPage).toHaveURL(/\/sign-in/); - - // Verify session is null in unauthenticated context - const unauthResponse = await unauthPage.request.get("/api/auth/session"); - const unauthSessionText = await unauthResponse.text(); - expect(unauthSessionText).toBe("null"); - - // Clean up - await authPage.close(); - await authContext.close(); - await unauthPage.close(); - await unauthContext.close(); - }); -}); - -test.describe("Navigation", () => { - test("should navigate to sign up page", async ({ page }) => { - await goToLogin(page); - await page.getByRole("link", { name: "Sign up" }).click(); - await expect(page).toHaveURL(URLS.SIGNUP); - }); - - test("should navigate from sign up back to sign in", async ({ page }) => { - await goToSignUp(page); - await page.getByRole("link", { name: "Log in" }).click(); - await expect(page).toHaveURL(URLS.LOGIN); - await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); - }); - - test("should handle browser back button correctly", async ({ page }) => { - await goToLogin(page); - await page.getByRole("link", { name: "Sign up" }).click(); - await expect(page).toHaveURL(URLS.SIGNUP); - await page.goBack(); - await expect(page).toHaveURL(URLS.LOGIN); - await expect(page.getByText("Sign in", { exact: true })).toBeVisible(); - }); -}); - -test.describe("Accessibility", () => { - test.beforeEach(async ({ page }) => { - await goToLogin(page); - }); - - test("should be navigable with keyboard", async ({ page }) => { - // Tab through form elements - await page.keyboard.press("Tab"); // Toggle theme - await page.keyboard.press("Tab"); // Email field - await expect(page.getByLabel("Email")).toBeFocused(); - - await page.keyboard.press("Tab"); // Password field - await expect(page.getByLabel("Password")).toBeFocused(); - - await page.keyboard.press("Tab"); // Show password button - await page.keyboard.press("Tab"); // Login button - - if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true") { - await page.keyboard.press("Tab"); // Forgot password - } - - await expect(page.getByRole("button", { name: "Log in" })).toBeFocused(); - }); - - test("should have proper ARIA labels", async ({ page }) => { - await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible(); - await expect(page.getByRole("textbox", { name: "Password" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Log in" })).toBeVisible(); - }); -}); diff --git a/ui/tests/sign-up/sign-up-page.ts b/ui/tests/sign-up/sign-up-page.ts index 7d53cb24a9..dd0822756c 100644 --- a/ui/tests/sign-up/sign-up-page.ts +++ b/ui/tests/sign-up/sign-up-page.ts @@ -54,13 +54,16 @@ export class SignUpPage extends BasePage { } async verifyPageLoaded(): Promise { - // Verify the sign up page is loaded - - await expect(this.page.getByRole("heading", { name: "Sign up" })).toBeVisible(); + await expect(this.page).toHaveURL("/sign-up"); await expect(this.emailInput).toBeVisible(); await expect(this.submitButton).toBeVisible(); } + async verifyOnSignUpPage(): Promise { + await expect(this.page).toHaveURL("/sign-up"); + await expect(this.emailInput).toBeVisible(); + } + async fillName(name: string): Promise { // Fill the name input @@ -141,5 +144,3 @@ export class SignUpPage extends BasePage { 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 index 957f88b00b..9114adaa6d 100644 --- a/ui/tests/sign-up/sign-up.spec.ts +++ b/ui/tests/sign-up/sign-up.spec.ts @@ -1,6 +1,6 @@ import { test } from "@playwright/test"; import { SignUpPage } from "./sign-up-page"; -import { SignInPage } from "../sign-in/sign-in-page"; +import { SignInPage } from "../sign-in-base/sign-in-base-page"; import { makeSuffix } from "../helpers"; test.describe("Sign Up Flow", () => {