/** * Comprehensive RBAC route permission matrix. * * Tests every route × every role (admin, editor, user, unauthenticated) * to verify the correct HTTP status code is returned. Also validates * cross-role session isolation and token edge cases. */ import { eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { db, schema } from "../../../apps/api/src/db/index.js"; import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js"; let testApp: TestApp; let adminToken: string; let editorToken: string; let userToken: string; const runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); beforeAll(async () => { testApp = await buildTestApp(); adminToken = await loginAsAdmin(testApp.app); // Create editor const editorUsername = `full_editor_${runId}`; await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username: editorUsername, password: "EditorPass1", role: "editor" }, }); await db .update(schema.users) .set({ mustChangePassword: false }) .where(eq(schema.users.username, editorUsername)); const editorLogin = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: editorUsername, password: "EditorPass1" }, }); editorToken = JSON.parse(editorLogin.body).token; // Create user const userUsername = `full_user_${runId}`; await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username: userUsername, password: "UserPass12", role: "user" }, }); await db .update(schema.users) .set({ mustChangePassword: false }) .where(eq(schema.users.username, userUsername)); const userLogin = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: userUsername, password: "UserPass12" }, }); userToken = JSON.parse(userLogin.body).token; }, 30_000); afterAll(async () => { await testApp.cleanup(); }, 10_000); // --------------------------------------------------------------------------- // Route permission matrix // --------------------------------------------------------------------------- interface RouteTest { method: "GET" | "POST" | "PUT" | "DELETE"; url: string; payload?: unknown | (() => unknown); admin: number; editor: number; user: number; unauth: number; label?: string; } const routes: RouteTest[] = [ // --- Public routes (no auth required) --- { method: "GET", url: "/api/v1/health", admin: 200, editor: 200, user: 200, unauth: 200, label: "public health check", }, { method: "GET", url: "/api/v1/config/auth", admin: 200, editor: 200, user: 200, unauth: 200, label: "public auth config", }, // --- Auth-only routes (any authenticated user) --- { method: "GET", url: "/api/v1/settings", admin: 200, editor: 200, user: 200, unauth: 401, label: "settings:read", }, { method: "GET", url: "/api/v1/files", admin: 200, editor: 200, user: 200, unauth: 401, label: "requireAuth", }, { method: "GET", url: "/api/v1/pipeline/list", admin: 200, editor: 200, user: 200, unauth: 401, label: "requireAuth", }, { method: "GET", url: "/api/v1/api-keys", admin: 200, editor: 200, user: 200, unauth: 401, label: "requireAuth", }, { method: "POST", url: "/api/v1/api-keys", payload: { name: `test-key-${runId}` }, admin: 201, editor: 201, user: 201, unauth: 401, label: "requireAuth (create api key)", }, // --- Admin-only routes --- { method: "PUT", url: "/api/v1/settings", payload: { _test: "v" }, admin: 200, editor: 403, user: 403, unauth: 401, label: "settings:write", }, { method: "GET", url: "/api/auth/users", admin: 200, editor: 403, user: 403, unauth: 401, label: "users:manage", }, { method: "POST", url: "/api/auth/register", payload: () => ({ username: `reg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, password: "TempPass1", role: "user", }), admin: 201, editor: 403, user: 403, unauth: 401, label: "users:manage (register)", }, { method: "GET", url: "/api/v1/teams", admin: 200, editor: 403, user: 403, unauth: 401, label: "teams:manage", }, { method: "POST", url: "/api/v1/teams", payload: () => ({ name: `team_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, }), admin: 201, editor: 403, user: 403, unauth: 401, label: "teams:manage (create)", }, { method: "GET", url: "/api/v1/roles", admin: 200, editor: 403, user: 403, unauth: 401, label: "audit:read (roles list)", }, { method: "POST", url: "/api/v1/roles", payload: () => ({ name: `role_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, permissions: ["tools:use", "files:own"], }), admin: 201, editor: 403, user: 403, unauth: 401, label: "users:manage (create role)", }, { method: "GET", url: "/api/v1/audit-log", admin: 200, editor: 403, user: 403, unauth: 401, label: "audit:read", }, { method: "GET", url: "/api/v1/admin/health", admin: 200, editor: 403, user: 403, unauth: 401, label: "system:health", }, ]; describe("RBAC route permission matrix (full)", () => { for (const route of routes) { for (const [role, expectedStatus] of Object.entries({ admin: route.admin, editor: route.editor, user: route.user, unauth: route.unauth, })) { const suffix = route.label ? ` [${route.label}]` : ""; it(`${route.method} ${route.url} -> ${role} = ${expectedStatus}${suffix}`, async () => { const headers: Record = {}; const token = role === "admin" ? adminToken : role === "editor" ? editorToken : role === "user" ? userToken : undefined; if (token) { headers.authorization = `Bearer ${token}`; } const payload = typeof route.payload === "function" ? route.payload() : route.payload; const res = await testApp.app.inject({ method: route.method, url: route.url, headers, ...(payload ? { payload } : {}), }); expect(res.statusCode).toBe(expectedStatus); }); } } }); // --------------------------------------------------------------------------- // Cross-role isolation // --------------------------------------------------------------------------- describe("Cross-role isolation", () => { it("editor session returns correct role and permissions", async () => { const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${editorToken}` }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.user.role).toBe("editor"); expect(body.user.permissions).toEqual( expect.arrayContaining([ "tools:use", "files:own", "files:all", "apikeys:own", "pipelines:own", "pipelines:all", "settings:read", ]), ); // Must NOT have admin-only permissions expect(body.user.permissions).not.toContain("settings:write"); expect(body.user.permissions).not.toContain("users:manage"); expect(body.user.permissions).not.toContain("teams:manage"); expect(body.user.permissions).not.toContain("features:manage"); expect(body.user.permissions).not.toContain("system:health"); expect(body.user.permissions).not.toContain("audit:read"); }); it("user session returns correct role and permissions", async () => { const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${userToken}` }, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.user.role).toBe("user"); expect(body.user.permissions).toEqual( expect.arrayContaining([ "tools:use", "files:own", "apikeys:own", "pipelines:own", "settings:read", ]), ); // Must NOT have editor or admin permissions expect(body.user.permissions).not.toContain("files:all"); expect(body.user.permissions).not.toContain("pipelines:all"); expect(body.user.permissions).not.toContain("settings:write"); expect(body.user.permissions).not.toContain("users:manage"); expect(body.user.permissions).not.toContain("teams:manage"); }); it("invalid token returns 401", async () => { const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: "Bearer totally-bogus-token-value" }, }); expect(res.statusCode).toBe(401); }); it("expired session returns 401", async () => { // Create a session, then manually expire it in the DB const expiredUsername = `expired_${runId}`; await testApp.app.inject({ method: "POST", url: "/api/auth/register", headers: { authorization: `Bearer ${adminToken}` }, payload: { username: expiredUsername, password: "ExpiredPass1", role: "user" }, }); await db .update(schema.users) .set({ mustChangePassword: false }) .where(eq(schema.users.username, expiredUsername)); const loginRes = await testApp.app.inject({ method: "POST", url: "/api/auth/login", payload: { username: expiredUsername, password: "ExpiredPass1" }, }); const expiredToken = JSON.parse(loginRes.body).token; // Manually expire the session await db .update(schema.sessions) .set({ expiresAt: new Date(Date.now() - 60_000) }) .where(eq(schema.sessions.id, expiredToken)); const res = await testApp.app.inject({ method: "GET", url: "/api/auth/session", headers: { authorization: `Bearer ${expiredToken}` }, }); expect(res.statusCode).toBe(401); }); });