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,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");
});
});
});
+45
View File
@@ -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<void>) {
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")}`,
);
}
}
@@ -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);
});
}
});