diff --git a/apps/web/e2e/dev-system/auth-session.spec.ts b/apps/web/e2e/dev-system/auth-session.spec.ts new file mode 100644 index 0000000..8dab820 --- /dev/null +++ b/apps/web/e2e/dev-system/auth-session.spec.ts @@ -0,0 +1,96 @@ +/** + * Auth & Session Registry tests — dev system + * + * Validates that: + * - Login works with the dev-DB seed users + * - After login, all tRPC calls succeed (no 401 "Session revoked") + * - After logout, protected routes redirect to sign-in + * - Invalid credentials are rejected + * + * These tests guard against regressions in the active-session registry + * (token.sid / active_sessions table) introduced in the auth hardening work. + */ +import { expect, test } from "@playwright/test"; +import { assertNoTrpc401s, DEV_USERS, signIn, signOut } from "./helpers.js"; + +test.describe("Auth — login / logout", () => { + test("admin login succeeds and lands on a protected page", async ({ page }) => { + await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password); + await expect(page).not.toHaveURL(/\/auth\/signin/); + }); + + test("manager login succeeds", async ({ page }) => { + await signIn(page, DEV_USERS.manager.email, DEV_USERS.manager.password); + await expect(page).not.toHaveURL(/\/auth\/signin/); + }); + + test("viewer login succeeds", async ({ page }) => { + await signIn(page, DEV_USERS.viewer.email, DEV_USERS.viewer.password); + await expect(page).not.toHaveURL(/\/auth\/signin/); + }); + + test("invalid credentials show an error and stay on sign-in", async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "nobody@example.com"); + await page.fill('input[type="password"]', "wrong"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 }); + // Error message visible + await expect( + page.locator("text=/invalid|incorrect|wrong|credentials/i"), + ).toBeVisible({ timeout: 5000 }); + }); + + test("after logout, protected routes redirect to sign-in", async ({ page }) => { + await signIn(page, DEV_USERS.admin.email, DEV_USERS.admin.password); + await signOut(page); + await page.goto("/dashboard"); + await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 10000 }); + }); +}); + +test.describe("Session registry — no tRPC 401s after login", () => { + test("admin login produces a valid 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"); + }); + + // 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({ + 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"); + }); + + // Page should render something (not blank) + await expect(page.locator("h1,h2").first()).toBeVisible({ timeout: 10000 }); + }); + + test("viewer login produces a valid session: no 401s on dashboard", 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/helpers.ts b/apps/web/e2e/dev-system/helpers.ts new file mode 100644 index 0000000..91d8f30 --- /dev/null +++ b/apps/web/e2e/dev-system/helpers.ts @@ -0,0 +1,45 @@ +import { expect, type Page } from "@playwright/test"; + +/** Dev-system credentials — these exist in the planarchy.dev seed data */ +export const DEV_USERS = { + admin: { email: "admin@planarchy.dev", password: "admin123" }, + manager: { email: "manager@planarchy.dev", password: "manager123" }, + viewer: { email: "viewer@planarchy.dev", password: "viewer123" }, +} as const; + +export async function signIn(page: Page, email: string, password: string) { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', password); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15000 }); +} + +export async function signOut(page: Page) { + await page.goto("/auth/signout"); + // Wait for redirect back to signin + await page.waitForURL(/\/auth\/signin/, { timeout: 10000 }); +} + +/** + * Intercept all tRPC batch responses and assert none return HTTP 401. + * Returns a list of intercepted tRPC paths that were called. + */ +export async function assertNoTrpc401s(page: Page, action: () => Promise) { + const failures: string[] = []; + + page.on("response", (response) => { + if (response.url().includes("/api/trpc/") && response.status() === 401) { + const url = new URL(response.url()); + failures.push(url.pathname + url.search.slice(0, 80)); + } + }); + + await action(); + + if (failures.length > 0) { + throw new Error( + `tRPC 401 responses detected (session registry / auth broken):\n${failures.join("\n")}`, + ); + } +} diff --git a/apps/web/e2e/dev-system/rbac-permissions.spec.ts b/apps/web/e2e/dev-system/rbac-permissions.spec.ts new file mode 100644 index 0000000..5f0dc58 --- /dev/null +++ b/apps/web/e2e/dev-system/rbac-permissions.spec.ts @@ -0,0 +1,122 @@ +/** + * RBAC / permissions tests — dev system + * + * Validates that role-based access control is enforced correctly + * against the running dev server with real seed data. + * + * 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"; + +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"); + + await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); + // Seed users have planarchy.dev or capakraken.dev email domains + await expect( + page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first(), + ).toBeVisible({ timeout: 10000 }); + }); + + 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 }); + }); + + 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") + .isVisible() + .catch(() => false); + + expect(isRedirected || hasForbiddenText).toBe(true); + }); +}); + +test.describe("RBAC — allocations access", () => { + 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("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"); + + await expect( + page.locator("text=/do not have permission to view allocations/i"), + ).toHaveCount(0, { timeout: 8000 }); + }); + + 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 + await expect( + page.locator("text=/permission|forbidden|not have permission/i"), + ).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"); + + // 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); + }); + } +}); diff --git a/apps/web/playwright.dev.config.ts b/apps/web/playwright.dev.config.ts new file mode 100644 index 0000000..bc757de --- /dev/null +++ b/apps/web/playwright.dev.config.ts @@ -0,0 +1,36 @@ +/** + * Playwright configuration for running E2E tests against the LIVE dev server. + * + * Unlike the default playwright.config.ts (which spins up a dedicated test + * server with isolated test data), this config targets the already-running + * dev server at localhost:3100 and exercises real dev-DB data. + * + * Usage: + * pnpm --filter @capakraken/web exec playwright test --config playwright.dev.config.ts + * + * Prerequisites: + * - Dev server running: pnpm run dev (or docker compose up) + * - Dev DB seeded with planarchy.dev seed users + */ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e/dev-system", + fullyParallel: false, + forbidOnly: !!process.env["CI"], + retries: 0, + workers: 1, + reporter: "list", + use: { + baseURL: "http://localhost:3100", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + // No webServer block — the dev server must already be running +}); diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index c3d0c5e..53c33b0 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -154,8 +154,9 @@ const authConfig = { if (token.role) { (session.user as typeof session.user & { role: string }).role = token.role as string; } - if (token.jti) { - (session.user as typeof session.user & { jti: string }).jti = token.jti as string; + // Use token.sid (not token.jti) to avoid conflict with Auth.js's internal JWT ID claim + if (token.sid) { + (session.user as typeof session.user & { jti: string }).jti = token.sid as string; } return session; }, @@ -163,9 +164,11 @@ const authConfig = { if (user) { token.role = (user as typeof user & { role: string }).role; - // Generate a unique JWT ID for session tracking + // Generate a unique session ID for tracking. + // We use token.sid (not token.jti) because Auth.js manages token.jti + // internally and may overwrite it after the jwt callback returns. const jti = crypto.randomUUID(); - token.jti = jti; + token.sid = jti; // Enforce concurrent session limit (kick-oldest strategy) try { @@ -208,7 +211,7 @@ const authConfig = { const token = "token" in message ? message.token : null; const userId = token?.sub ?? null; const email = token?.email ?? "unknown"; - const jti = token?.jti as string | undefined; + const jti = (token?.sid ?? token?.jti) as string | undefined; // Remove from active session registry if (jti) {