From 2a9300cc257edbd208037c929dc9c59c8bbe0eba Mon Sep 17 00:00:00 2001 From: favvy994 Date: Wed, 29 Jul 2026 09:08:07 +0100 Subject: [PATCH] test(dashboard): Playwright e2e tests for Privy dashboard auth guards Closes #611 - dashboard/e2e/auth-guards.spec.ts: integration tests covering: - Unauthenticated users redirected to /login for all protected routes - Authenticated users (mocked localStorage token) can access /dashboard pages - Token removal simulates logout and triggers redirect - Login form field validation and error display on failed auth - dashboard/playwright.config.ts: Playwright config with chromium, dev server integration, and CI environment support - dashboard/package.json: added @playwright/test devDependency with e2e scripts - Auth mocked via page.addInitScript injecting localStorage token (no live Privy backend required in pipeline environments) --- dashboard/e2e/auth-guards.spec.ts | 217 ++++++++++++++++++++++++++++++ dashboard/package.json | 6 +- dashboard/playwright.config.ts | 43 ++++++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 dashboard/e2e/auth-guards.spec.ts create mode 100644 dashboard/playwright.config.ts diff --git a/dashboard/e2e/auth-guards.spec.ts b/dashboard/e2e/auth-guards.spec.ts new file mode 100644 index 0000000..a8ddfe6 --- /dev/null +++ b/dashboard/e2e/auth-guards.spec.ts @@ -0,0 +1,217 @@ +/** + * dashboard/e2e/auth-guards.spec.ts + * + * Integration tests for Privy dashboard auth guards. + * + * Acceptance criteria + * ------------------- + * ✓ Unauthenticated users are redirected to /login when visiting protected pages. + * ✓ Authenticated users can access protected dashboard pages without redirect. + * ✓ Auth is validated via mocked localStorage token (pipeline-safe override). + * + * Mock strategy + * ------------- + * The dashboard layout reads `localStorage.getItem("token")` to determine auth + * state. Tests inject or clear this value via `page.addInitScript` before + * navigating, so no real Privy or backend connection is required in CI. + */ + +import { test, expect, Page } from "@playwright/test"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Navigate to `url` without any auth token in localStorage. + * Simulates an unauthenticated / logged-out user. + */ +async function visitAsGuest(page: Page, url: string): Promise { + // Clear any pre-existing storage state and inject a clean localStorage. + await page.addInitScript(() => { + window.localStorage.clear(); + }); + await page.goto(url); +} + +/** + * Navigate to `url` with a mock auth token already in localStorage. + * Simulates a logged-in user without a real Privy session. + * + * @param token - A fake JWT-shaped value; only presence matters for the guard. + */ +async function visitAsAuthenticated( + page: Page, + url: string, + token = "mock-auth-token-for-e2e" +): Promise { + await page.addInitScript((t) => { + window.localStorage.setItem("token", t); + }, token); + await page.goto(url); +} + +// ── Protected routes to exercise ───────────────────────────────────────────── + +const PROTECTED_ROUTES = [ + "/dashboard", + "/dashboard/payouts", + "/dashboard/transactions", + "/dashboard/yield", + "/dashboard/analytics", +]; + +// ── Auth guard — unauthenticated access ────────────────────────────────────── + +test.describe("Auth guard: unauthenticated users", () => { + for (const route of PROTECTED_ROUTES) { + test(`redirects guest from ${route} to /login`, async ({ page }) => { + await visitAsGuest(page, route); + + // The layout's useEffect replaces the route with /login when no token is found. + await page.waitForURL("**/login", { timeout: 8_000 }); + + expect(page.url()).toContain("/login"); + }); + } + + test("login page is accessible without a token", async ({ page }) => { + await visitAsGuest(page, "/login"); + + // Should stay on /login — not redirect elsewhere. + await page.waitForLoadState("networkidle"); + expect(page.url()).toContain("/login"); + }); + + test("login page renders sign-in form for unauthenticated users", async ({ + page, + }) => { + await visitAsGuest(page, "/login"); + + await expect(page.getByText("Zaps Merchant")).toBeVisible(); + await expect(page.getByPlaceholder("Your user ID")).toBeVisible(); + await expect(page.getByPlaceholder("••••")).toBeVisible(); + await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible(); + }); + + test("direct navigation to /dashboard root redirects guest to /login", async ({ + page, + }) => { + await visitAsGuest(page, "/dashboard"); + await page.waitForURL("**/login", { timeout: 8_000 }); + expect(page.url()).toContain("/login"); + // Confirm protected content is not visible + await expect(page.getByText("Zaps Merchant")).toBeVisible(); + }); +}); + +// ── Auth guard — authenticated access ──────────────────────────────────────── + +test.describe("Auth guard: authenticated users", () => { + test("authenticated user can reach /dashboard without redirect", async ({ + page, + }) => { + await visitAsAuthenticated(page, "/dashboard"); + + // Wait for the page to settle. If the guard fires, it would push to /login. + await page.waitForLoadState("networkidle"); + + // Should remain on a dashboard URL, not be pushed to /login. + expect(page.url()).not.toContain("/login"); + }); + + test("mock token is present in localStorage during session", async ({ + page, + }) => { + await visitAsAuthenticated(page, "/dashboard"); + await page.waitForLoadState("networkidle"); + + const token = await page.evaluate(() => + window.localStorage.getItem("token") + ); + expect(token).toBeTruthy(); + }); + + test("authenticated user can navigate to /dashboard/payouts", async ({ + page, + }) => { + await visitAsAuthenticated(page, "/dashboard/payouts"); + await page.waitForLoadState("networkidle"); + + expect(page.url()).not.toContain("/login"); + }); + + test("authenticated user can navigate to /dashboard/yield", async ({ + page, + }) => { + await visitAsAuthenticated(page, "/dashboard/yield"); + await page.waitForLoadState("networkidle"); + + expect(page.url()).not.toContain("/login"); + }); +}); + +// ── Auth guard — token removal (logout) ────────────────────────────────────── + +test.describe("Auth guard: token removal simulates logout", () => { + test("clearing token then navigating to /dashboard redirects to /login", async ({ + page, + }) => { + // Start authenticated + await visitAsAuthenticated(page, "/dashboard"); + await page.waitForLoadState("networkidle"); + + // Simulate logout by clearing localStorage + await page.evaluate(() => window.localStorage.removeItem("token")); + + // Navigate to a protected page fresh — should now be redirected + await page.goto("/dashboard"); + await page.waitForURL("**/login", { timeout: 8_000 }); + expect(page.url()).toContain("/login"); + }); +}); + +// ── Login form validation ───────────────────────────────────────────────────── + +test.describe("Login form: field validation", () => { + test("submit button is present and form fields are required", async ({ + page, + }) => { + await visitAsGuest(page, "/login"); + + const userIdInput = page.getByPlaceholder("Your user ID"); + const pinInput = page.getByPlaceholder("••••"); + const submitBtn = page.getByRole("button", { name: /sign in/i }); + + await expect(userIdInput).toBeVisible(); + await expect(pinInput).toBeVisible(); + await expect(submitBtn).toBeEnabled(); + + // HTML5 required validation: attempting submit with empty fields + // should not proceed (browser prevents form submission). + await submitBtn.click(); + + // Page should still be on /login (no navigation occurred). + expect(page.url()).toContain("/login"); + }); + + test("error message is shown on failed login attempt", async ({ page }) => { + // Mock the API call to reject credentials + await page.route("**/api/auth/**", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "Unauthorized" }), + }) + ); + + await visitAsGuest(page, "/login"); + + await page.getByPlaceholder("Your user ID").fill("invalid-user"); + await page.getByPlaceholder("••••").fill("0000"); + await page.getByRole("button", { name: /sign in/i }).click(); + + // The login page shows an error message on failure + await expect( + page.getByText(/invalid user id or pin/i) + ).toBeVisible({ timeout: 6_000 }); + }); +}); diff --git a/dashboard/package.json b/dashboard/package.json index aec6f10..bf01785 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -6,7 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:report": "playwright show-report" }, "dependencies": { "@stellar/freighter-api": "6.0.1", @@ -22,6 +25,7 @@ "recharts": "^3.8.1" }, "devDependencies": { + "@playwright/test": "^1.48.2", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", diff --git a/dashboard/playwright.config.ts b/dashboard/playwright.config.ts new file mode 100644 index 0000000..7352956 --- /dev/null +++ b/dashboard/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright configuration for dashboard e2e tests. + * + * Auth guard tests rely on a running Next.js dev server. Set PLAYWRIGHT_BASE_URL + * in CI to override the default local URL. + * + * Pipeline auth override: tests mock localStorage `token` directly to simulate + * authenticated / unauthenticated states without a live Privy backend. + */ +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000", + trace: "on-first-retry", + // Ensure localStorage manipulation is possible before navigation + storageState: undefined, + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + /* Start the Next.js dev server when running locally */ + webServer: process.env.CI + ? undefined + : { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: true, + timeout: 120_000, + }, +});