Skip to content

Commit 2a9300c

Browse files
committed
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)
1 parent af6e582 commit 2a9300c

3 files changed

Lines changed: 265 additions & 1 deletion

File tree

dashboard/e2e/auth-guards.spec.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* dashboard/e2e/auth-guards.spec.ts
3+
*
4+
* Integration tests for Privy dashboard auth guards.
5+
*
6+
* Acceptance criteria
7+
* -------------------
8+
* ✓ Unauthenticated users are redirected to /login when visiting protected pages.
9+
* ✓ Authenticated users can access protected dashboard pages without redirect.
10+
* ✓ Auth is validated via mocked localStorage token (pipeline-safe override).
11+
*
12+
* Mock strategy
13+
* -------------
14+
* The dashboard layout reads `localStorage.getItem("token")` to determine auth
15+
* state. Tests inject or clear this value via `page.addInitScript` before
16+
* navigating, so no real Privy or backend connection is required in CI.
17+
*/
18+
19+
import { test, expect, Page } from "@playwright/test";
20+
21+
// ── Helpers ──────────────────────────────────────────────────────────────────
22+
23+
/**
24+
* Navigate to `url` without any auth token in localStorage.
25+
* Simulates an unauthenticated / logged-out user.
26+
*/
27+
async function visitAsGuest(page: Page, url: string): Promise<void> {
28+
// Clear any pre-existing storage state and inject a clean localStorage.
29+
await page.addInitScript(() => {
30+
window.localStorage.clear();
31+
});
32+
await page.goto(url);
33+
}
34+
35+
/**
36+
* Navigate to `url` with a mock auth token already in localStorage.
37+
* Simulates a logged-in user without a real Privy session.
38+
*
39+
* @param token - A fake JWT-shaped value; only presence matters for the guard.
40+
*/
41+
async function visitAsAuthenticated(
42+
page: Page,
43+
url: string,
44+
token = "mock-auth-token-for-e2e"
45+
): Promise<void> {
46+
await page.addInitScript((t) => {
47+
window.localStorage.setItem("token", t);
48+
}, token);
49+
await page.goto(url);
50+
}
51+
52+
// ── Protected routes to exercise ─────────────────────────────────────────────
53+
54+
const PROTECTED_ROUTES = [
55+
"/dashboard",
56+
"/dashboard/payouts",
57+
"/dashboard/transactions",
58+
"/dashboard/yield",
59+
"/dashboard/analytics",
60+
];
61+
62+
// ── Auth guard — unauthenticated access ──────────────────────────────────────
63+
64+
test.describe("Auth guard: unauthenticated users", () => {
65+
for (const route of PROTECTED_ROUTES) {
66+
test(`redirects guest from ${route} to /login`, async ({ page }) => {
67+
await visitAsGuest(page, route);
68+
69+
// The layout's useEffect replaces the route with /login when no token is found.
70+
await page.waitForURL("**/login", { timeout: 8_000 });
71+
72+
expect(page.url()).toContain("/login");
73+
});
74+
}
75+
76+
test("login page is accessible without a token", async ({ page }) => {
77+
await visitAsGuest(page, "/login");
78+
79+
// Should stay on /login — not redirect elsewhere.
80+
await page.waitForLoadState("networkidle");
81+
expect(page.url()).toContain("/login");
82+
});
83+
84+
test("login page renders sign-in form for unauthenticated users", async ({
85+
page,
86+
}) => {
87+
await visitAsGuest(page, "/login");
88+
89+
await expect(page.getByText("Zaps Merchant")).toBeVisible();
90+
await expect(page.getByPlaceholder("Your user ID")).toBeVisible();
91+
await expect(page.getByPlaceholder("••••")).toBeVisible();
92+
await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible();
93+
});
94+
95+
test("direct navigation to /dashboard root redirects guest to /login", async ({
96+
page,
97+
}) => {
98+
await visitAsGuest(page, "/dashboard");
99+
await page.waitForURL("**/login", { timeout: 8_000 });
100+
expect(page.url()).toContain("/login");
101+
// Confirm protected content is not visible
102+
await expect(page.getByText("Zaps Merchant")).toBeVisible();
103+
});
104+
});
105+
106+
// ── Auth guard — authenticated access ────────────────────────────────────────
107+
108+
test.describe("Auth guard: authenticated users", () => {
109+
test("authenticated user can reach /dashboard without redirect", async ({
110+
page,
111+
}) => {
112+
await visitAsAuthenticated(page, "/dashboard");
113+
114+
// Wait for the page to settle. If the guard fires, it would push to /login.
115+
await page.waitForLoadState("networkidle");
116+
117+
// Should remain on a dashboard URL, not be pushed to /login.
118+
expect(page.url()).not.toContain("/login");
119+
});
120+
121+
test("mock token is present in localStorage during session", async ({
122+
page,
123+
}) => {
124+
await visitAsAuthenticated(page, "/dashboard");
125+
await page.waitForLoadState("networkidle");
126+
127+
const token = await page.evaluate(() =>
128+
window.localStorage.getItem("token")
129+
);
130+
expect(token).toBeTruthy();
131+
});
132+
133+
test("authenticated user can navigate to /dashboard/payouts", async ({
134+
page,
135+
}) => {
136+
await visitAsAuthenticated(page, "/dashboard/payouts");
137+
await page.waitForLoadState("networkidle");
138+
139+
expect(page.url()).not.toContain("/login");
140+
});
141+
142+
test("authenticated user can navigate to /dashboard/yield", async ({
143+
page,
144+
}) => {
145+
await visitAsAuthenticated(page, "/dashboard/yield");
146+
await page.waitForLoadState("networkidle");
147+
148+
expect(page.url()).not.toContain("/login");
149+
});
150+
});
151+
152+
// ── Auth guard — token removal (logout) ──────────────────────────────────────
153+
154+
test.describe("Auth guard: token removal simulates logout", () => {
155+
test("clearing token then navigating to /dashboard redirects to /login", async ({
156+
page,
157+
}) => {
158+
// Start authenticated
159+
await visitAsAuthenticated(page, "/dashboard");
160+
await page.waitForLoadState("networkidle");
161+
162+
// Simulate logout by clearing localStorage
163+
await page.evaluate(() => window.localStorage.removeItem("token"));
164+
165+
// Navigate to a protected page fresh — should now be redirected
166+
await page.goto("/dashboard");
167+
await page.waitForURL("**/login", { timeout: 8_000 });
168+
expect(page.url()).toContain("/login");
169+
});
170+
});
171+
172+
// ── Login form validation ─────────────────────────────────────────────────────
173+
174+
test.describe("Login form: field validation", () => {
175+
test("submit button is present and form fields are required", async ({
176+
page,
177+
}) => {
178+
await visitAsGuest(page, "/login");
179+
180+
const userIdInput = page.getByPlaceholder("Your user ID");
181+
const pinInput = page.getByPlaceholder("••••");
182+
const submitBtn = page.getByRole("button", { name: /sign in/i });
183+
184+
await expect(userIdInput).toBeVisible();
185+
await expect(pinInput).toBeVisible();
186+
await expect(submitBtn).toBeEnabled();
187+
188+
// HTML5 required validation: attempting submit with empty fields
189+
// should not proceed (browser prevents form submission).
190+
await submitBtn.click();
191+
192+
// Page should still be on /login (no navigation occurred).
193+
expect(page.url()).toContain("/login");
194+
});
195+
196+
test("error message is shown on failed login attempt", async ({ page }) => {
197+
// Mock the API call to reject credentials
198+
await page.route("**/api/auth/**", (route) =>
199+
route.fulfill({
200+
status: 401,
201+
contentType: "application/json",
202+
body: JSON.stringify({ error: "Unauthorized" }),
203+
})
204+
);
205+
206+
await visitAsGuest(page, "/login");
207+
208+
await page.getByPlaceholder("Your user ID").fill("invalid-user");
209+
await page.getByPlaceholder("••••").fill("0000");
210+
await page.getByRole("button", { name: /sign in/i }).click();
211+
212+
// The login page shows an error message on failure
213+
await expect(
214+
page.getByText(/invalid user id or pin/i)
215+
).toBeVisible({ timeout: 6_000 });
216+
});
217+
});

dashboard/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
"dev": "next dev",
77
"build": "next build",
88
"start": "next start",
9-
"lint": "eslint"
9+
"lint": "eslint",
10+
"e2e": "playwright test",
11+
"e2e:ui": "playwright test --ui",
12+
"e2e:report": "playwright show-report"
1013
},
1114
"dependencies": {
1215
"@stellar/freighter-api": "6.0.1",
@@ -22,6 +25,7 @@
2225
"recharts": "^3.8.1"
2326
},
2427
"devDependencies": {
28+
"@playwright/test": "^1.48.2",
2529
"@tailwindcss/postcss": "^4",
2630
"@types/node": "^20",
2731
"@types/react": "^19",

dashboard/playwright.config.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
/**
4+
* Playwright configuration for dashboard e2e tests.
5+
*
6+
* Auth guard tests rely on a running Next.js dev server. Set PLAYWRIGHT_BASE_URL
7+
* in CI to override the default local URL.
8+
*
9+
* Pipeline auth override: tests mock localStorage `token` directly to simulate
10+
* authenticated / unauthenticated states without a live Privy backend.
11+
*/
12+
export default defineConfig({
13+
testDir: "./e2e",
14+
fullyParallel: true,
15+
forbidOnly: !!process.env.CI,
16+
retries: process.env.CI ? 2 : 0,
17+
workers: process.env.CI ? 1 : undefined,
18+
reporter: process.env.CI ? "github" : "list",
19+
20+
use: {
21+
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
22+
trace: "on-first-retry",
23+
// Ensure localStorage manipulation is possible before navigation
24+
storageState: undefined,
25+
},
26+
27+
projects: [
28+
{
29+
name: "chromium",
30+
use: { ...devices["Desktop Chrome"] },
31+
},
32+
],
33+
34+
/* Start the Next.js dev server when running locally */
35+
webServer: process.env.CI
36+
? undefined
37+
: {
38+
command: "npm run dev",
39+
url: "http://localhost:3000",
40+
reuseExistingServer: true,
41+
timeout: 120_000,
42+
},
43+
});

0 commit comments

Comments
 (0)