mike/e2e/auth-flows.spec.ts
Amal 94987e6bd7 test: Playwright e2e suite (auth, chat, projects, tabular reviews, workflows)
Port of the amal66/mike fork's Playwright end-to-end suite onto the
upstream backend/ + frontend/ layout:

- e2e/: auth.setup (bootstraps a confirmed e2e@mike.local user via the
  Supabase admin API and saves storageState), auth-flows, critical-path
  (create project -> upload PDF -> ask a question -> streamed response),
  chat-management, project-management, tabular-reviews,
  workflows-account; fixtures/test.pdf
- playwright.config.ts: single-worker (shared test user), setup project
  + chromium project with saved auth state; webServer adapted from the
  fork's monorepo command (npm run dev --workspace apps/web) to
  upstream's layouts: backend `npm run dev` (health-checked on :3001)
  and frontend `npm run dev` (:3000)
- root package.json (upstream has none): @playwright/test, typescript
  dev-deps and test:e2e scripts, trimmed from the fork's root manifest
- root tsconfig.json scoped to e2e/ + playwright.config.ts so
  `npx tsc --noEmit` typechecks the suite
- .gitignore: playwright artifacts + e2e/.auth (session tokens)

Specs select by ARIA role/name and placeholder text only - no
data-testid attributes, so no app-code changes are required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CEguyEgXa9JjCciXCcVemC
2026-07-20 09:52:46 -07:00

174 lines
8 KiB
TypeScript

/**
* Authentication flow E2E tests:
* 1. Login: invalid credentials show an error message
* 2. Login: valid credentials redirect to /assistant
* 3. Logout redirects to /login
* 4. All protected routes redirect unauthenticated users to /login
*
* Tests 1, 2, and 4 run in a fresh browser context (no stored session).
* Test 3 inherits the authenticated storageState from the Playwright project
* config (e2e/.auth/user.json), so auth.setup.ts must run first.
*/
import { test, expect } from "@playwright/test";
/* ─── Unauthenticated tests ───────────────────────────────────────────────── */
/* describe-scoped test.use so only these tests run without a stored session.
File-level test.use would wipe the storageState for the authenticated
logout test below. */
test.describe("unauthenticated", () => {
test.use({ storageState: { cookies: [], origins: [] } });
/* ── Test 1: invalid credentials show error ──────────────────────────── */
test("login with invalid credentials shows error message", async ({
page,
}) => {
await page.goto("/login");
await expect(page).toHaveURL(/\/login/);
await page.fill("#email", "e2e@mike.local");
await page.fill("#password", "definitely-wrong-password");
await page.click('button[type="submit"]');
/* Wait for the client-side async signIn call to complete and for React
to set the `error` state and re-render the error element. */
await page.waitForLoadState("networkidle");
/* The login page conditionally renders:
<div className="text-red-600 text-sm bg-red-50 p-3 rounded">
{error}
</div>
when the `error` state is non-null after a failed signInWithPassword.
REGRESSION: fails if the error <div className="bg-red-50"> is removed
from the login form or if the catch block stops setting `error`. */
await expect(page.locator("div.bg-red-50.text-red-600")).toBeVisible({
timeout: 10_000,
});
});
/* ── Test 2: valid credentials redirect to /assistant ─────────────────── */
test("login with valid credentials redirects to /assistant", async ({
page,
}) => {
/* Use the SAME credentials auth.setup.ts bootstrapped the shared user
with. Both read process.env.E2E_EMAIL / E2E_PASSWORD (falling back to
the local defaults). CI overrides E2E_PASSWORD to a value DIFFERENT
from the old hardcoded "E2eTestPass1!", so hardcoding it here typed a
password the user was never created with → signInWithPassword failed,
the error banner rendered, and the /assistant redirect never fired.
Reading the env keeps the typed password in lock-step with the
bootstrapped one in every environment. */
const email = process.env.E2E_EMAIL ?? "e2e@mike.local";
const password = process.env.E2E_PASSWORD ?? "E2eTestPass1!";
await page.goto("/login");
await expect(page).toHaveURL(/\/login/);
await page.fill("#email", email);
await page.fill("#password", password);
await page.click('button[type="submit"]');
/* REGRESSION: fails if `router.push("/assistant")` is removed from
the handleLogin success branch in frontend/src/app/login/page.tsx. */
await expect(page).toHaveURL(/\/assistant/, { timeout: 15_000 });
});
/* ── Test 4: all protected routes redirect to /login ─────────────────── */
test("all protected routes redirect unauthenticated users to /login", async ({
page,
}) => {
/* Every route under the (pages) route group is protected by the layout
auth guard:
if (!authLoading && !isAuthenticated) { router.push("/login"); }
in frontend/src/app/(pages)/layout.tsx.
REGRESSION: fails if that router.push("/login") is removed from the
layout, or if any of these routes is moved outside the (pages) group
without adding its own auth guard. */
const protectedRoutes = [
"/projects",
"/tabular-reviews",
"/workflows",
"/account",
];
for (const route of protectedRoutes) {
await page.goto(route);
/* Auth check is client-side (Supabase getSession) — allow time for
the async check to resolve and for Next.js router.push to fire. */
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
}
});
});
/* ─── Authenticated tests ─────────────────────────────────────────────────── */
/* ── Test 3: logout redirects to /login ─────────────────────────────────── */
/* The logout flow calls supabase.auth.signOut(), which defaults to GLOBAL
scope and revokes the user's session server-side. If this ran against the
shared `e2e@mike.local` user it would 401 every other parallel worker
("Invalid or expired token"). So this test starts from a clean session and
logs in as a DEDICATED user (created in auth.setup.ts) whose session can be
safely destroyed without affecting any other test. */
test.describe("logout (isolated user)", () => {
test.use({ storageState: { cookies: [], origins: [] } });
const logoutEmail =
process.env.E2E_LOGOUT_EMAIL ?? "e2e-logout@mike.local";
const logoutPassword =
process.env.E2E_LOGOUT_PASSWORD ?? "E2eLogoutPass1!";
test("logout from account settings redirects to /login", async ({
page,
}) => {
/* Log in fresh as the dedicated logout user. */
await page.goto("/login");
await expect(page).toHaveURL(/\/login/);
await page.fill("#email", logoutEmail);
await page.fill("#password", logoutPassword);
await page.click('button[type="submit"]');
await page.waitForURL(/\/assistant/, { timeout: 15_000 });
await page.waitForLoadState("networkidle");
/* The AppSidebar renders a user-profile toggle button at the very bottom
of the sidebar. The button wraps a circular div that shows the user's
initial:
<div className="h-7 w-7 ... rounded-full bg-gray-700 ...">
{getUserInitials(user.email)}
</div>
Locate the button by the presence of that inner div. */
const userMenuButton = page.locator("button").filter({
has: page.locator("div.rounded-full.bg-gray-700"),
});
await expect(userMenuButton).toBeVisible({ timeout: 10_000 });
await userMenuButton.click();
/* The dropdown that appears contains an "Account Settings" button which
navigates to /account via router.push("/account"). */
const accountSettingsItem = page.getByRole("button", {
name: "Account Settings",
});
await expect(accountSettingsItem).toBeVisible({ timeout: 5_000 });
await accountSettingsItem.click();
await expect(page).toHaveURL(/\/account/, { timeout: 10_000 });
await page.waitForLoadState("networkidle");
/* The /account page has a "Sign Out" button that calls:
await signOut();
router.push("/");
The root "/" page redirects to "/assistant", and the (pages) layout auth
guard then redirects the now-unauthenticated user to "/login".
REGRESSION: fails if signOut() is removed from handleLogout in
frontend/src/app/(pages)/account/page.tsx. */
const signOutButton = page.getByRole("button", { name: "Sign Out" });
await expect(signOutButton).toBeVisible({ timeout: 5_000 });
await signOutButton.click();
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
});
});