import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import type { AppEnv, StaffRow } from "../middleware/rbac.js"; // ─── Mock staff data ────────────────────────────────────────────────────────── const MANAGER: StaffRow = { id: "staff-manager-id", oidcSub: "oidc-manager-sub", userId: "ba-user-manager", role: "manager", isSuperUser: true, name: "Manager McManager", email: "manager@example.com", active: true, icalToken: null, createdAt: new Date(), updatedAt: new Date(), }; const RECEPTIONIST: StaffRow = { ...MANAGER, id: "staff-receptionist-id", oidcSub: "oidc-receptionist-sub", userId: "ba-user-receptionist", role: "receptionist", isSuperUser: false, name: "Receptionist Rita", email: "receptionist@example.com", }; const GROOMER: StaffRow = { ...MANAGER, id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", userId: "ba-user-groomer", role: "groomer", isSuperUser: false, name: "Groomer Gary", email: "groomer@example.com", }; // ─── Mock DB ────────────────────────────────────────────────────────────────── let staffLookupResult: StaffRow | null = null; let managerFallbackResult: StaffRow | null = MANAGER; vi.mock("@groombook/db", () => { const staff = new Proxy( { _name: "staff" }, { get(target, prop) { if (prop === "_name") return "staff"; if (prop === "$inferSelect") return {}; return { table: "staff", column: prop }; }, } ); return { getDb: () => ({ select: () => ({ from: () => ({ where: () => ({ limit: () => { // dev mode fallback to first manager return managerFallbackResult ? [managerFallbackResult] : []; }, [Symbol.iterator]: function* () { if (staffLookupResult) yield staffLookupResult; }, 0: staffLookupResult, length: staffLookupResult ? 1 : 0, }), }), }), }), staff, eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })), and: vi.fn((..._clauses: unknown[]) => ({})), }; }); // ─── Helpers ────────────────────────────────────────────────────────────────── function resetMocks() { staffLookupResult = null; managerFallbackResult = MANAGER; } /** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */ function buildApp( middleware: MiddlewareHandler, handler?: (c: Context) => Response | Promise ) { const app = new Hono(); app.use("*", async (c, next) => { c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" }); await next(); }); app.use("*", middleware); const h = handler ?? ((c: Context) => c.json({ ok: true })); app.get("/test", h); app.post("/test", h); return app; } /** Build app with staff pre-set in context (skips resolveStaffMiddleware). */ function buildWithStaff( staffRow: StaffRow, guard: MiddlewareHandler ) { const app = new Hono(); app.use("*", async (c, next) => { c.set("jwtPayload", { sub: staffRow.userId ?? "" }); c.set("staff", staffRow); await next(); }); app.use("*", guard); app.get("/test", (c) => c.json({ ok: true })); app.post("/test", (c) => c.json({ ok: true })); return app; } // ─── Import middleware ──────────────────────────────────────────────────────── const { resolveStaffMiddleware, requireRole, requireSuperUser, requireRoleOrSuperUser } = await import( "../middleware/rbac.js" ); beforeEach(() => resetMocks()); afterEach(() => { delete process.env.AUTH_DISABLED; }); // ─── resolveStaffMiddleware tests ───────────────────────────────────────────── describe("resolveStaffMiddleware", () => { it("resolves staff from DB and sets it on context", async () => { staffLookupResult = MANAGER; let capturedStaff: StaffRow | null = null; const app = buildApp(resolveStaffMiddleware, (c) => { capturedStaff = c.get("staff"); return c.json({ ok: true }); }); const res = await app.request("/test"); expect(res.status).toBe(200); expect(capturedStaff).not.toBeNull(); expect(capturedStaff!.id).toBe(MANAGER.id); }); it("returns 403 when no staff record found for the OIDC sub", async () => { staffLookupResult = null; const app = buildApp(resolveStaffMiddleware); const res = await app.request("/test"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/no staff record/i); }); it("dev mode: resolves staff by X-Dev-User-Id header", async () => { process.env.AUTH_DISABLED = "true"; staffLookupResult = GROOMER; let capturedStaff: StaffRow | null = null; const app = buildApp(resolveStaffMiddleware, (c) => { capturedStaff = c.get("staff"); return c.json({ ok: true }); }); const res = await app.request("/test", { headers: { "X-Dev-User-Id": GROOMER.id }, }); expect(res.status).toBe(200); expect(capturedStaff!.role).toBe("groomer"); }); it("dev mode: falls back to first manager when no X-Dev-User-Id header", async () => { process.env.AUTH_DISABLED = "true"; managerFallbackResult = MANAGER; let capturedStaff: StaffRow | null = null; const app = buildApp(resolveStaffMiddleware, (c) => { capturedStaff = c.get("staff"); return c.json({ ok: true }); }); const res = await app.request("/test"); expect(res.status).toBe(200); expect(capturedStaff!.role).toBe("manager"); }); it("dev mode: returns 403 when no manager exists and no header provided", async () => { process.env.AUTH_DISABLED = "true"; managerFallbackResult = null; const app = buildApp(resolveStaffMiddleware); const res = await app.request("/test"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/no staff records found/i); }); }); // ─── requireRole tests ──────────────────────────────────────────────────────── describe("requireRole", () => { it("allows access when staff role matches the only allowed role", async () => { const app = buildWithStaff(MANAGER, requireRole("manager")); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("allows access when staff role is one of multiple allowed roles", async () => { const app = buildWithStaff(RECEPTIONIST, requireRole("manager", "receptionist")); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("returns 403 for an unauthorized role", async () => { const app = buildWithStaff(GROOMER, requireRole("manager")); const res = await app.request("/test"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/forbidden/i); expect(body.error).toContain("groomer"); }); it("includes the role name in the 403 error message", async () => { const app = buildWithStaff(RECEPTIONIST, requireRole("manager")); const res = await app.request("/test"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toContain("receptionist"); }); it("groomer is blocked from manager+receptionist-only routes", async () => { const app = buildWithStaff(GROOMER, requireRole("manager", "receptionist")); const res = await app.request("/test", { method: "POST" }); expect(res.status).toBe(403); }); it("manager passes all-role checks", async () => { const app = buildWithStaff(MANAGER, requireRole("manager", "receptionist", "groomer")); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("returns 403 with JSON body (not plain text)", async () => { const app = buildWithStaff(GROOMER, requireRole("manager")); const res = await app.request("/test"); expect(res.status).toBe(403); const contentType = res.headers.get("content-type") ?? ""; expect(contentType).toContain("application/json"); }); }); // ─── requireSuperUser tests ───────────────────────────────────────────────── describe("requireSuperUser", () => { it("allows access when staff is a super user", async () => { const app = buildWithStaff(MANAGER, requireSuperUser()); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("allows access when manager is also a super user", async () => { // MANAGER has isSuperUser: true const app = buildWithStaff(MANAGER, requireSuperUser()); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("returns 403 for a non-super-user receptionist", async () => { // RECEPTIONIST has isSuperUser: false const app = buildWithStaff(RECEPTIONIST, requireSuperUser()); const res = await app.request("/test"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/super user privileges required/i); }); it("returns 403 for a non-super-user groomer", async () => { // GROOMER has isSuperUser: false const app = buildWithStaff(GROOMER, requireSuperUser()); const res = await app.request("/test"); expect(res.status).toBe(403); }); it("returns 403 when staff record is not resolved", async () => { // Manually remove staff from context to simulate unresolved staff const testApp = new Hono(); testApp.use("*", async (c, next) => { c.set("jwtPayload", { sub: "test-sub" }); // Do NOT set staff - simulate unresolved staff await next(); }); testApp.use("*", requireSuperUser()); testApp.get("/test", (c) => c.json({ ok: true })); const res = await testApp.request("/test"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/staff record not resolved/i); }); it("receptionist cannot grant super user status on staff PATCH", async () => { // This tests the inline guard in staff.ts handler, not the middleware itself, // but we test requireSuperUser to verify the middleware correctly blocks const app = buildWithStaff(RECEPTIONIST, requireSuperUser()); const res = await app.request("/test", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isSuperUser: true }), }); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/super user privileges required/i); }); it("returns 403 with JSON body for super user violation", async () => { const app = buildWithStaff(RECEPTIONIST, requireSuperUser()); const res = await app.request("/test"); expect(res.status).toBe(403); const contentType = res.headers.get("content-type") ?? ""; expect(contentType).toContain("application/json"); }); }); // ─── requireRoleOrSuperUser tests ───────────────────────────────────────────── describe("requireRoleOrSuperUser", () => { it("allows a manager to access manager-only routes", async () => { const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager")); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("allows a super user with receptionist role to access manager-only routes (GRO-412 bug fix)", async () => { // GRO-412: a receptionist granted super user via Staff UI should access admin routes const superReceptionist: StaffRow = { ...RECEPTIONIST, isSuperUser: true, }; const app = buildWithStaff(superReceptionist, requireRoleOrSuperUser("manager")); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("allows a super user with groomer role to access manager-only routes", async () => { const superGroomer: StaffRow = { ...GROOMER, isSuperUser: true, }; const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager")); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("blocks a non-super-user receptionist from manager-only routes", async () => { const app = buildWithStaff(RECEPTIONIST, requireRoleOrSuperUser("manager")); const res = await app.request("/test"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/role.*not permitted/i); }); it("blocks a non-super-user groomer from manager-only routes", async () => { const app = buildWithStaff(GROOMER, requireRoleOrSuperUser("manager")); const res = await app.request("/test"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/role.*not permitted/i); }); it("allows a manager with multiple allowed roles", async () => { const app = buildWithStaff(MANAGER, requireRoleOrSuperUser("manager", "receptionist")); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("allows a super user with disallowed role to access route with multiple allowed roles", async () => { const superGroomer: StaffRow = { ...GROOMER, isSuperUser: true, }; const app = buildWithStaff(superGroomer, requireRoleOrSuperUser("manager", "receptionist")); const res = await app.request("/test"); expect(res.status).toBe(200); }); });