test(ui-e2e): reorganize auth tests and add documentation (#9788)

Co-authored-by: pedrooot <pedromarting3@gmail.com>
This commit is contained in:
Alan Buscaglia
2026-01-27 12:53:24 +01:00
committed by GitHub
parent dce406b39b
commit 255ce0e866
28 changed files with 1506 additions and 811 deletions
+91 -4
View File
@@ -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<void> {
await this.page.goto(path);
await this.page.waitForLoadState("networkidle");
// Child classes should override verifyPageLoaded() to wait for specific elements
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
// Override in child classes to wait for page-specific elements
async verifyPageLoaded(): Promise<void> {
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<void> {
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
+4 -4
View File
@@ -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"
+13 -4
View File
@@ -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@",
},
},
});
-92
View File
@@ -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");
}
});
});
-124
View File
@@ -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
});
});
-102
View File
@@ -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");
});
});
+66
View File
@@ -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
});
+88
View File
@@ -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");
},
);
});
+78
View File
@@ -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();
},
);
});
+231
View File
@@ -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.
+13 -5
View File
@@ -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<void> {
await this.page.goto(url, { waitUntil: "commit" });
}
async refresh(): Promise<void> {
await this.page.reload();
}
@@ -130,28 +138,28 @@ export abstract class BasePage {
// Common wait methods
async waitForElement(element: Locator, timeout: number = 5000): Promise<void> {
await element.waitFor({ timeout });
}
async waitForElementToDisappear(element: Locator, timeout: number = 5000): Promise<void> {
await element.waitFor({ state: "hidden", timeout });
}
async waitForUrl(expectedUrl: string | RegExp, timeout: number = 5000): Promise<void> {
await this.page.waitForURL(expectedUrl, { timeout });
}
// Common screenshot methods
async takeScreenshot(name: string): Promise<void> {
await this.page.screenshot({ path: `screenshots/${name}.png` });
}
async takeElementScreenshot(element: Locator, name: string): Promise<void> {
await element.screenshot({ path: `screenshots/${name}.png` });
}
}
+2 -140
View File
@@ -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
+13 -14
View File
@@ -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<void> {
await this.userMenu.click();
}
async signOut(): Promise<void> {
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();
}
+1 -1
View File
@@ -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", () => {
+9 -2
View File
@@ -15,11 +15,18 @@ export class UserProfilePage extends BasePage {
}
async goto(): Promise<void> {
// Navigate to the user profile page
await super.goto("/profile");
}
async verifyPageLoaded(): Promise<void> {
await expect(this.page).toHaveURL("/profile");
await expect(this.pageHeadingUserProfile).toBeVisible();
}
async verifyOnProfilePage(): Promise<void> {
await expect(this.page).toHaveURL("/profile");
}
async verifyOrganizationId(organizationId: string): Promise<void> {
// Verify the organization ID is visible
+3 -2
View File
@@ -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);
});
@@ -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);
});
+3 -2
View File
@@ -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);
});
@@ -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);
});
@@ -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);
});
+3 -2
View File
@@ -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);
});
@@ -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);
});
@@ -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 <p> 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 <p> 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<void> {
await this.page.goto(`/sign-in?error=${errorType}`);
}
// Form interaction methods
async fillEmail(email: string): Promise<void> {
await this.emailInput.fill(email);
@@ -137,7 +159,8 @@ export class SignInPage extends BasePage {
async verifyPageLoaded(): Promise<void> {
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<void> {
@@ -157,7 +180,9 @@ export class SignInPage extends BasePage {
}
async verifyNavigationLinks(): Promise<void> {
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<void> {
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<void> {
await this.goto();
await this.login(credentials);
await this.verifySuccessfulLogin();
}
async verifyLoginError(
errorMessage: string = "Invalid email or password",
): Promise<void> {
// Error messages appear as <p> 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<void> {
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<void> {
// Check that button shows loading text or is disabled
await expect(this.loginButton).toHaveAttribute("aria-disabled", "true");
await super.verifyLoadingState();
}
async verifyFormValidation(): Promise<void> {
// 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 <p> 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<void> {
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<void> {
await this.homePage.signOut();
}
async verifyLogoutSuccess(): Promise<void> {
/**
* Verifies we are on the sign-in page (URL + title visible)
* Use this when redirected to sign-in from protected routes
*/
async verifyOnSignInPage(): Promise<void> {
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<void> {
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<void> {
await expect(this.page).not.toHaveURL(/\/sign-in/);
}
/**
* Verifies redirect to sign-in includes a callbackUrl parameter
*/
async verifyRedirectWithCallback(expectedCallback: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 });
}
}
+446
View File
@@ -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.
+262
View File
@@ -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();
},
);
});
-256
View File
@@ -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();
});
});
+6 -5
View File
@@ -54,13 +54,16 @@ export class SignUpPage extends BasePage {
}
async verifyPageLoaded(): Promise<void> {
// 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<void> {
await expect(this.page).toHaveURL("/sign-up");
await expect(this.emailInput).toBeVisible();
}
async fillName(name: string): Promise<void> {
// Fill the name input
@@ -141,5 +144,3 @@ export class SignUpPage extends BasePage {
await expect(this.page).toHaveURL("/email-verification");
}
}
+1 -1
View File
@@ -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", () => {