mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
test(ui-e2e): reorganize auth tests and add documentation (#9788)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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@",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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", () => {
|
||||
|
||||
Reference in New Issue
Block a user