/** * RBAC data-access matrix — dev system * * Verifies that role-based access control is enforced at the network level * (tRPC response payload) against the running dev server with real seed data. * * Unlike rbac-permissions.spec.ts (which checks UI visibility), these tests * call tRPC procedures directly via fetch() inside the browser context and * assert on HTTP status + tRPC error codes in the response body. * * All tests use pre-authenticated storage states — no signIn() calls, no * auth rate limiter pressure. 3 logins total per suite run (from globalSetup). * * Tested procedures and their audience classes (docs/route-access-matrix.md): * * user.list → admin-only (adminProcedure) * allocation.listView → planning-read (planningReadProcedure → VIEW_PLANNING) * resource.listSummaries → resource-overview (resourceOverviewProcedure) * user.listAssignable → manager-write (managerProcedure → ADMIN or MANAGER) * * Expected access matrix: * * Procedure ADMIN MANAGER VIEWER * user.list ✓ FORBIDDEN FORBIDDEN * allocation.listView ✓ ✓ FORBIDDEN * resource.listSummaries ✓ ✓ FORBIDDEN * user.listAssignable ✓ ✓ FORBIDDEN */ import { expect, test, type Page } from "@playwright/test"; import { STORAGE_STATE } from "../../playwright.dev.config.js"; // --------------------------------------------------------------------------- // Helper — call a tRPC query procedure directly from within the browser context // --------------------------------------------------------------------------- type TrpcQueryResult = { httpStatus: number; trpcCode: string | null; hasData: boolean; }; /** * Runs a tRPC GET query inside the browser context (inherits the session cookie). * Returns the HTTP status, the tRPC error code (null on success), and whether * a non-null `result.data` was returned. * * tRPC v11 batch GET format: * /api/trpc/?batch=1&input={"0":{"json":}} * Success response: [{"result":{"data":{"json": ...}}}] * Error response: [{"error":{"json":{"data":{"code":"FORBIDDEN","httpStatus":403}}}}] */ async function trpcQuery( page: Page, procedure: string, input: unknown = null, ): Promise { return page.evaluate( async ({ procedure, input }) => { const encodedInput = encodeURIComponent( JSON.stringify({ "0": { json: input } }), ); const url = `/api/trpc/${procedure}?batch=1&input=${encodedInput}`; const res = await fetch(url, { credentials: "include" }); const httpStatus = res.status; // tRPC v11 with no transformer: no extra .json wrapper around the payload. // Error format: [{"error":{"message":"...","code":-32603,"data":{"code":"FORBIDDEN","httpStatus":403}}}] // Success format: [{"result":{"data": }}] type TrpcBatchItem = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string }; }; const body = (await res.json()) as TrpcBatchItem[]; const item = body[0]; const trpcCode = item?.error?.data?.code ?? null; const hasData = item?.result?.data !== undefined && item.result.data !== null; return { httpStatus, trpcCode, hasData } satisfies { httpStatus: number; trpcCode: string | null; hasData: boolean; }; }, { procedure, input }, ); } // --------------------------------------------------------------------------- // Admin — should have access to all procedures // --------------------------------------------------------------------------- test.describe("RBAC data-access — admin", () => { test.use({ storageState: STORAGE_STATE.admin }); test("admin: user.list returns data (admin-only procedure)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "user.list"); expect(result.trpcCode).toBeNull(); expect(result.httpStatus).toBe(200); expect(result.hasData).toBe(true); }); test("admin: allocation.listView returns data (planning-read)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "allocation.listView", {}); expect(result.trpcCode).toBeNull(); expect(result.httpStatus).toBe(200); }); test("admin: resource.listSummaries returns data (resource-overview)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "resource.listSummaries"); expect(result.trpcCode).toBeNull(); expect(result.httpStatus).toBe(200); }); test("admin: user.listAssignable returns data (manager-write procedure)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "user.listAssignable"); expect(result.trpcCode).toBeNull(); expect(result.httpStatus).toBe(200); }); }); // --------------------------------------------------------------------------- // Manager — FORBIDDEN on admin-only, allowed on planning-read/resource-overview/manager // --------------------------------------------------------------------------- test.describe("RBAC data-access — manager", () => { test.use({ storageState: STORAGE_STATE.manager }); test("manager: user.list is FORBIDDEN (admin-only procedure)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "user.list"); expect(result.trpcCode).toBe("FORBIDDEN"); }); test("manager: allocation.listView returns data (planning-read)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "allocation.listView", {}); expect(result.trpcCode).toBeNull(); expect(result.httpStatus).toBe(200); }); test("manager: resource.listSummaries returns data (resource-overview)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "resource.listSummaries"); expect(result.trpcCode).toBeNull(); expect(result.httpStatus).toBe(200); }); test("manager: user.listAssignable returns data (manager-write procedure)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "user.listAssignable"); expect(result.trpcCode).toBeNull(); expect(result.httpStatus).toBe(200); }); }); // --------------------------------------------------------------------------- // Viewer — FORBIDDEN on all sensitive procedures // --------------------------------------------------------------------------- test.describe("RBAC data-access — viewer", () => { test.use({ storageState: STORAGE_STATE.viewer }); test("viewer: user.list is FORBIDDEN (admin-only procedure)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "user.list"); expect(result.trpcCode).toBe("FORBIDDEN"); }); test("viewer: allocation.listView is FORBIDDEN (planning-read — no VIEW_PLANNING)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "allocation.listView", {}); expect(result.trpcCode).toBe("FORBIDDEN"); }); test("viewer: resource.listSummaries is FORBIDDEN (resource-overview)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "resource.listSummaries"); expect(result.trpcCode).toBe("FORBIDDEN"); }); test("viewer: user.listAssignable is FORBIDDEN (manager-write procedure)", async ({ page, }) => { await page.goto("/dashboard"); await page.waitForLoadState("networkidle"); const result = await trpcQuery(page, "user.listAssignable"); expect(result.trpcCode).toBe("FORBIDDEN"); }); });