import { test, expect } from "@playwright/test"; import * as fx from "../src/test/fixtures"; /** * One smoke path: /login → submit → /home renders the real dashboard. * * There's no live Go backend in CI, so `page.route` intercepts * `/api/auth/login` and `/api/v1/*` at the BROWSER network layer, before * either request ever reaches the Next server — the real * `src/app/api/auth/login/route.ts` handler (which calls the Go backend, * strips `token`, and calls `buildSetCookie`) never runs in this test; this * spec fabricates the `Set-Cookie` header and the token-free `{user}` body * itself. What DOES run for real: the Next dev server's page rendering, the * client-side login form + navigation, and — critically — the * `src/middleware.ts` route guard, which reads the `mercury_session` cookie * this test's fake `Set-Cookie` puts in the browser's real cookie jar to * decide whether `/home` is reachable. So this smoke covers the * login → navigate → guarded-route → dashboard-render path end to end; it * does NOT cover the real auth route's token-stripping/cookie-building logic * (that's covered separately by src/app/api/auth/auth.route.test.ts). The * httpOnly/no-raw-token assertions below validate this test's own mocked * response shape, i.e. they document the contract the real route must also * satisfy — they are not independent proof that the real route satisfies it. */ test("login → home renders the dashboard; session cookie is httpOnly; no raw token leaks", async ({ page, context, }) => { const responseBodies: string[] = []; page.on("response", (response) => { response .text() .then((body) => responseBodies.push(body)) .catch(() => { /* non-text bodies (redirects, static assets, etc.) — not relevant here */ }); }); await page.route("**/api/auth/login", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", headers: { "set-cookie": "mercury_session=e2e-fake-session; Path=/; HttpOnly; SameSite=Lax", }, body: JSON.stringify({ user: fx.user }), }); }); await page.route("**/api/v1/**", async (route) => { const url = new URL(route.request().url()); const path = url.pathname.replace(/^\/api\/v1/, ""); const body: unknown = path === "/networth" ? fx.netWorth : path === "/analyze" ? url.searchParams.has("from") || url.searchParams.has("to") ? fx.analyzeToday : fx.analyzeMonth : path === "/budget" ? fx.budget : path === "/transactions" ? { transactions: [fx.txn] } : path === "/categories" ? { categories: fx.categories } : path === "/subscriptions" ? { subscriptions: fx.subscriptions, bills: fx.bills } : path === "/manual-assets" ? { manualAssets: fx.manualAssets } : path === "/lending" ? { entries: [fx.lending] } : path === "/settings" ? fx.settings : path === "/connections" ? { connections: fx.connections } : path === "/me" ? { user: fx.user } : {}; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); }); await page.goto("/login"); await page.getByLabel("Имэйл хаяг").fill("test@example.com"); await page.getByLabel("Нууц үг").fill("password12345"); await page.getByRole("button", { name: "Нэвтрэх" }).click(); await page.waitForURL("**/home"); // The dashboard header wordmark, and the real safe-to-spend figure derived // from the intercepted /networth + /analyze + /budget fixtures (900,000 // monthLimit − 800,000 discretionaryExpense — see buildHome.test.ts). await expect(page.getByText("MERCURY")).toBeVisible(); await expect(page.getByText("100,000₮")).toBeVisible(); const cookies = await context.cookies(); const session = cookies.find((c) => c.name === "mercury_session"); expect(session, "mercury_session cookie should be set after login").toBeTruthy(); expect(session?.httpOnly).toBe(true); const leaked = responseBodies.some((body) => /"token"\s*:\s*"/.test(body)); expect(leaked, "no observed response body should contain a raw token field").toBe(false); });