fix(auth): use token.sid to avoid Auth.js jti claim conflict

Auth.js v5 manages token.jti internally and overwrites it after the jwt
callback. Storing our session UUID in token.sid ensures the value we
persist in active_sessions matches what the signed cookie carries.

- jwt callback: token.sid = jti (was token.jti)
- session callback: read from token.sid
- signOut event: falls back to token.jti for backward compat with any
  sessions created before this change

Also adds Playwright dev-system test suite (playwright.dev.config.ts +
e2e/dev-system/) that validates login, session registry health, and
RBAC enforcement against the running localhost:3100 dev server.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-04-01 19:00:44 +02:00
parent a867672afa
commit 3d8a256d52
5 changed files with 307 additions and 5 deletions
@@ -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);
});
}
});