diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..3f5dde8 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1 @@ +e2e/dev-system/.auth/ diff --git a/apps/web/e2e/dev-system/auth-session.spec.ts b/apps/web/e2e/dev-system/auth-session.spec.ts index 8dab820..cf37edb 100644 --- a/apps/web/e2e/dev-system/auth-session.spec.ts +++ b/apps/web/e2e/dev-system/auth-session.spec.ts @@ -9,8 +9,14 @@ * * These tests guard against regressions in the active-session registry * (token.sid / active_sessions table) introduced in the auth hardening work. + * + * Login-flow tests (describe "Auth") exercise the actual login form and count + * against the rate limiter. Session-registry tests (describe "Session registry") + * reuse pre-authenticated storage state from global setup to avoid rate-limit + * exhaustion when the whole suite runs sequentially. */ import { expect, test } from "@playwright/test"; +import { STORAGE_STATE } from "../../playwright.dev.config.js"; import { assertNoTrpc401s, DEV_USERS, signIn, signOut } from "./helpers.js"; test.describe("Auth — login / logout", () => { @@ -49,18 +55,19 @@ test.describe("Auth — login / logout", () => { }); }); +// Session-registry tests reuse stored login state so they don't add to the +// rate-limit counter for the admin account. test.describe("Session registry — no tRPC 401s after login", () => { - test("admin login produces a valid session: dashboard loads without 401s", async ({ page }) => { + test.use({ storageState: STORAGE_STATE.admin }); + + test("admin session: dashboard loads without 401s", async ({ page }) => { await assertNoTrpc401s(page, async () => { - await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password); await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); }); }); test("admin navigating to /admin/users fires no 401s and loads user rows", async ({ page }) => { - await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password); - await assertNoTrpc401s(page, async () => { await page.goto("/admin/users"); await page.waitForLoadState("networkidle"); @@ -68,15 +75,13 @@ test.describe("Session registry — no tRPC 401s after login", () => { // At least one user row should be visible await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); - await expect(page.locator("text=/planarchy\.dev|capakraken\.dev/")).toBeVisible({ + await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({ timeout: 10000, }); await expect(page.locator("text=No users found")).toHaveCount(0); }); test("admin navigating to /admin/system-roles fires no 401s", async ({ page }) => { - await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password); - await assertNoTrpc401s(page, async () => { await page.goto("/admin/system-roles"); await page.waitForLoadState("networkidle"); @@ -86,9 +91,23 @@ test.describe("Session registry — no tRPC 401s after login", () => { await expect(page.locator("h1,h2").first()).toBeVisible({ timeout: 10000 }); }); - test("viewer login produces a valid session: no 401s on dashboard", async ({ page }) => { + test("viewer session: no 401s on dashboard", async ({ page }) => { + // Override admin storageState for this one test + await page.context().clearCookies(); + // Use viewer state via a fresh context — covered separately in rbac-permissions.spec.ts + // Here we just verify no residual 401s from a page refresh after session restore + await assertNoTrpc401s(page, async () => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + }); + }); +}); + +test.describe("Session registry — viewer no 401s", () => { + test.use({ storageState: STORAGE_STATE.viewer }); + + test("viewer session: dashboard loads without 401s", async ({ page }) => { await assertNoTrpc401s(page, async () => { - await signIn(page, DEV_USERS.viewer.email, DEV_USERS.viewer.password); await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); }); diff --git a/apps/web/e2e/dev-system/global-setup.ts b/apps/web/e2e/dev-system/global-setup.ts new file mode 100644 index 0000000..a011188 --- /dev/null +++ b/apps/web/e2e/dev-system/global-setup.ts @@ -0,0 +1,46 @@ +/** + * Playwright global setup for dev-system tests. + * + * Logs in once per user role and saves browser storage state to disk. + * Tests that don't need to exercise the login flow itself can use these + * cached states via `test.use({ storageState: '...' })` to avoid + * hitting the auth rate limiter (5 attempts / 15 min / email). + */ +import { chromium, type FullConfig } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; + +const USERS = { + admin: { email: "admin@planarchy.dev", password: "admin123" }, + manager: { email: "manager@planarchy.dev", password: "manager123" }, + viewer: { email: "viewer@planarchy.dev", password: "viewer123" }, +} as const; + +async function globalSetup(config: FullConfig) { + const baseURL = config.projects[0]?.use?.baseURL ?? "http://localhost:3100"; + const authDir = path.join(__dirname, ".auth"); + fs.mkdirSync(authDir, { recursive: true }); + + const browser = await chromium.launch(); + + for (const [role, creds] of Object.entries(USERS)) { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${baseURL}/auth/signin`); + await page.fill('input[type="email"]', creds.email); + await page.fill('input[type="password"]', creds.password); + await page.click('button[type="submit"]'); + + // Wait for successful redirect + await page.waitForURL(/\/(dashboard|resources)/, { timeout: 15000 }); + + await context.storageState({ path: path.join(authDir, `${role}.json`) }); + await context.close(); + console.log(`[global-setup] Saved auth state for ${role}`); + } + + await browser.close(); +} + +export default globalSetup; diff --git a/apps/web/e2e/dev-system/helpers.ts b/apps/web/e2e/dev-system/helpers.ts index 91d8f30..c8babeb 100644 --- a/apps/web/e2e/dev-system/helpers.ts +++ b/apps/web/e2e/dev-system/helpers.ts @@ -17,7 +17,12 @@ export async function signIn(page: Page, email: string, password: string) { export async function signOut(page: Page) { await page.goto("/auth/signout"); - // Wait for redirect back to signin + // Auth.js v5 renders a confirmation page at /auth/signout before signing out. + // Click the submit button if a form is present. + const confirmBtn = page.locator('button[type="submit"]').first(); + if (await confirmBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await confirmBtn.click(); + } await page.waitForURL(/\/auth\/signin/, { timeout: 10000 }); } diff --git a/apps/web/e2e/dev-system/rbac-permissions.spec.ts b/apps/web/e2e/dev-system/rbac-permissions.spec.ts index 5f0dc58..2e08e1b 100644 --- a/apps/web/e2e/dev-system/rbac-permissions.spec.ts +++ b/apps/web/e2e/dev-system/rbac-permissions.spec.ts @@ -4,17 +4,25 @@ * Validates that role-based access control is enforced correctly * against the running dev server with real seed data. * + * All tests reuse pre-authenticated storage states from global setup + * so they don't trigger the auth rate limiter. + * * Role hierarchy: * ADMIN — full access including all /admin/* routes * MANAGER — can view allocations, cannot access /admin/users etc. * VIEWER — limited read-only; allocations are forbidden (TRPC FORBIDDEN) */ import { expect, test } from "@playwright/test"; -import { DEV_USERS, signIn } from "./helpers.js"; +import { STORAGE_STATE } from "../../playwright.dev.config.js"; + +// --------------------------------------------------------------------------- +// Admin routes — admin session +// --------------------------------------------------------------------------- + +test.describe("RBAC — admin routes (admin session)", () => { + test.use({ storageState: STORAGE_STATE.admin }); -test.describe("RBAC — admin routes", () => { test("admin can access /admin/users and sees user rows", async ({ page }) => { - await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password); await page.goto("/admin/users"); await page.waitForLoadState("networkidle"); @@ -26,45 +34,35 @@ test.describe("RBAC — admin routes", () => { }); test("admin can access /admin/system-roles without errors", async ({ page }) => { - await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password); await page.goto("/admin/system-roles"); await page.waitForLoadState("networkidle"); - // Page should render a heading, not a blank screen or error await expect(page.locator("h1,h2").first()).toBeVisible({ timeout: 10000 }); }); test("admin can access /admin/blueprints without errors", async ({ page }) => { - await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password); await page.goto("/admin/blueprints"); await page.waitForLoadState("networkidle"); await expect(page.locator("h1,h2,h3").first()).toBeVisible({ timeout: 10000 }); }); +}); + +// --------------------------------------------------------------------------- +// Admin routes — non-admin sessions should be blocked +// --------------------------------------------------------------------------- + +test.describe("RBAC — /admin/users blocked for manager", () => { + test.use({ storageState: STORAGE_STATE.manager }); test("manager is redirected or sees an error on /admin/users", async ({ page }) => { - await signIn(page, DEV_USERS.manager.email, DEV_USERS.manager.password); - await page.goto("/admin/users"); - await page.waitForLoadState("networkidle"); - - // Expect either: redirect away from /admin/users, or a permission-denied message - const isRedirected = !page.url().includes("/admin/users"); - const hasForbiddenText = await page - .locator("text=/forbidden|permission|access denied|not authorized|unauthorized/i") - .isVisible() - .catch(() => false); - - expect(isRedirected || hasForbiddenText).toBe(true); - }); - - test("viewer is redirected or sees an error on /admin/users", async ({ page }) => { - await signIn(page, DEV_USERS.viewer.email, DEV_USERS.viewer.password); await page.goto("/admin/users"); await page.waitForLoadState("networkidle"); const isRedirected = !page.url().includes("/admin/users"); const hasForbiddenText = await page .locator("text=/forbidden|permission|access denied|not authorized|unauthorized/i") + .first() .isVisible() .catch(() => false); @@ -72,20 +70,45 @@ test.describe("RBAC — admin routes", () => { }); }); -test.describe("RBAC — allocations access", () => { +test.describe("RBAC — /admin/users blocked for viewer", () => { + test.use({ storageState: STORAGE_STATE.viewer }); + + test("viewer is redirected or sees an error on /admin/users", async ({ page }) => { + await page.goto("/admin/users"); + await page.waitForLoadState("networkidle"); + + const isRedirected = !page.url().includes("/admin/users"); + const hasForbiddenText = await page + .locator("text=/forbidden|permission|access denied|not authorized|unauthorized/i") + .first() + .isVisible() + .catch(() => false); + + expect(isRedirected || hasForbiddenText).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Allocations access by role +// --------------------------------------------------------------------------- + +test.describe("RBAC — allocations permitted for admin", () => { + test.use({ storageState: STORAGE_STATE.admin }); + test("admin can view /allocations without permission errors", async ({ page }) => { - await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password); await page.goto("/allocations"); await page.waitForLoadState("networkidle"); - // Should NOT show the FORBIDDEN/permission-denied overlay await expect( page.locator("text=/do not have permission to view allocations/i"), ).toHaveCount(0, { timeout: 8000 }); }); +}); + +test.describe("RBAC — allocations permitted for manager", () => { + test.use({ storageState: STORAGE_STATE.manager }); test("manager can view /allocations without permission errors", async ({ page }) => { - await signIn(page, DEV_USERS.manager.email, DEV_USERS.manager.password); await page.goto("/allocations"); await page.waitForLoadState("networkidle"); @@ -93,30 +116,56 @@ test.describe("RBAC — allocations access", () => { page.locator("text=/do not have permission to view allocations/i"), ).toHaveCount(0, { timeout: 8000 }); }); +}); + +test.describe("RBAC — allocations forbidden for viewer", () => { + test.use({ storageState: STORAGE_STATE.viewer }); test("viewer sees a permission error on /allocations", async ({ page }) => { - await signIn(page, DEV_USERS.viewer.email, DEV_USERS.viewer.password); await page.goto("/allocations"); await page.waitForLoadState("networkidle"); - // AllocationsClient renders a permission-denied message when tRPC returns FORBIDDEN + // AllocationsClient renders the message in both the page subtitle and the card body; + // use .first() to avoid strict-mode violation. await expect( - page.locator("text=/permission|forbidden|not have permission/i"), + page.locator("text=/permission|forbidden|not have permission/i").first(), ).toBeVisible({ timeout: 10000 }); }); }); -test.describe("RBAC — dashboard loads for all roles", () => { - for (const [role, creds] of Object.entries(DEV_USERS)) { - test(`${role} dashboard loads without errors`, async ({ page }) => { - await signIn(page, creds.email, creds.password); - await page.goto("/dashboard"); - await page.waitForLoadState("networkidle"); +// --------------------------------------------------------------------------- +// Dashboard loads for every role +// --------------------------------------------------------------------------- - // Should be on dashboard, not bounced to sign-in - await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 5000 }); - // No unhandled error overlay - await expect(page.locator("text=/500|Internal Server Error/i")).toHaveCount(0); - }); - } +test.describe("RBAC — dashboard (admin)", () => { + test.use({ storageState: STORAGE_STATE.admin }); + + test("admin dashboard loads without errors", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 5000 }); + await expect(page.locator("text=/500|Internal Server Error/i")).toHaveCount(0); + }); +}); + +test.describe("RBAC — dashboard (manager)", () => { + test.use({ storageState: STORAGE_STATE.manager }); + + test("manager dashboard loads without errors", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 5000 }); + await expect(page.locator("text=/500|Internal Server Error/i")).toHaveCount(0); + }); +}); + +test.describe("RBAC — dashboard (viewer)", () => { + test.use({ storageState: STORAGE_STATE.viewer }); + + test("viewer dashboard loads without errors", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 5000 }); + await expect(page.locator("text=/500|Internal Server Error/i")).toHaveCount(0); + }); }); diff --git a/apps/web/playwright.dev.config.ts b/apps/web/playwright.dev.config.ts index bc757de..bf44a41 100644 --- a/apps/web/playwright.dev.config.ts +++ b/apps/web/playwright.dev.config.ts @@ -13,9 +13,17 @@ * - Dev DB seeded with planarchy.dev seed users */ import { defineConfig, devices } from "@playwright/test"; +import * as path from "path"; + +export const STORAGE_STATE = { + admin: path.join(__dirname, "e2e/dev-system/.auth/admin.json"), + manager: path.join(__dirname, "e2e/dev-system/.auth/manager.json"), + viewer: path.join(__dirname, "e2e/dev-system/.auth/viewer.json"), +}; export default defineConfig({ testDir: "./e2e/dev-system", + globalSetup: "./e2e/dev-system/global-setup.ts", fullyParallel: false, forbidOnly: !!process.env["CI"], retries: 0,