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 ────────────────────────────────────────────────────────────────── // staffLookupResult drives every `from(staff)` query that doesn't go through // the dev-mode `.limit()` shortcut. Tests that want to simulate "no staff row" // leave it null. let staffLookupResult: StaffRow | null = null; // managerFallbackResult is only consumed by the dev-mode `from(staff).limit(1)` // path (looking up the first manager when AUTH_DISABLED=true and no header). let managerFallbackResult: StaffRow | null = MANAGER; // userLookupResult drives `from(user).limit(1)` for the Better-Auth user // auto-provision branch (GRO-2052). Tests that simulate "no Better-Auth user" // leave it null. type UserRow = { id: string; name: string | null; email: string | null }; let userLookupResult: UserRow | null = null; // accountLookupResult drives `from(account).limit(1)` for the legacy OIDC // auto-provision branch. Null means "no OIDC account row". let accountLookupResult: { id: string } | null = null; // insertReturningResult drives `insert(staff).values(...).returning()` for // any auto-provision branch that actually creates a staff record. Null means // the INSERT returned no rows (simulating a DB failure). let insertReturningResult: StaffRow | null = null; vi.mock("@groombook/db", () => { function tableMarker(name: string) { return new Proxy( { _name: name }, { get(_target, prop) { if (prop === "_name") return name; if (prop === "$inferSelect") return {}; return { table: name, column: prop }; }, } ); } const staff = tableMarker("staff"); const user = tableMarker("user"); const account = tableMarker("account"); function lookupFor(tableName: string) { if (tableName === "user") return userLookupResult; if (tableName === "account") return accountLookupResult; return staffLookupResult; } return { getDb: () => ({ select: (_columns?: unknown) => ({ from: (table: { _name?: string }) => { const name = table?._name ?? "staff"; return { where: () => ({ limit: () => { // The user / account auto-provision branches always call // `.limit(1)`; route to the per-table lookup state. if (name === "user") return userLookupResult ? [userLookupResult] : []; if (name === "account") return accountLookupResult ? [accountLookupResult] : []; // dev-mode `from(staff).limit(1)` falls back to the first // manager when AUTH_DISABLED is set with no header. return managerFallbackResult ? [managerFallbackResult] : []; }, [Symbol.iterator]: function* () { const row = lookupFor(name); if (row) yield row; }, 0: lookupFor(name), length: lookupFor(name) ? 1 : 0, }), }; }, }), insert: (_table: unknown) => ({ values: (_v: unknown) => ({ returning: () => insertReturningResult ? [insertReturningResult] : [], }), }), update: (_table: unknown) => ({ set: (_v: unknown) => ({ where: () => Promise.resolve(undefined), }), }), }), staff, user, account, eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })), and: vi.fn((..._clauses: unknown[]) => ({})), sql: Object.assign( vi.fn((..._tpl: unknown[]) => ({})), { raw: vi.fn(() => ({})) } ), }; }); // ─── Helpers ────────────────────────────────────────────────────────────────── function resetMocks() { staffLookupResult = null; managerFallbackResult = MANAGER; userLookupResult = null; accountLookupResult = null; insertReturningResult = null; } /** Build a minimal Hono app with jwtPayload pre-set, then apply a middleware. */ function buildApp( middleware: MiddlewareHandler, handler?: (c: Context) => Response | Promise, jwtOverride?: Partial<{ sub: string; email: string; name: string }> ) { const app = new Hono(); app.use("*", async (c, next) => { const defaultSub = staffLookupResult?.userId ?? "unknown-sub"; c.set("jwtPayload", { sub: jwtOverride?.sub ?? defaultSub, ...(jwtOverride?.email !== undefined ? { email: jwtOverride.email } : {}), ...(jwtOverride?.name !== undefined ? { name: jwtOverride.name } : {}), }); 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); }); }); // ─── Auto-provision branches (GRO-2052) ─────────────────────────────────────── // // Each branch creates a staff row on first authenticated request when no row // exists yet. The Better-Auth branch (user table) is the primary path for // email/password customers; the OIDC branch (account table) is a fallback for // legacy authentik/google/github sessions. describe("resolveStaffMiddleware — auto-provision", () => { const PROVISIONED: StaffRow = { ...MANAGER, id: "staff-provisioned-id", oidcSub: null, userId: "ba-user-customer", role: "groomer", isSuperUser: false, name: "UAT Customer", email: "uat-customer@groombook.dev", }; it("Better-Auth: creates a groomer staff row when user exists but no staff record (GRO-2052)", async () => { // No existing staff row, no OIDC account row, but a Better-Auth user row. staffLookupResult = null; userLookupResult = { id: "ba-user-customer", name: "UAT Customer", email: "uat-customer@groombook.dev", }; accountLookupResult = null; insertReturningResult = PROVISIONED; let capturedStaff: StaffRow | null = null; const app = buildApp( resolveStaffMiddleware, (c) => { capturedStaff = c.get("staff"); return c.json({ ok: true }); }, { sub: "ba-user-customer", email: "uat-customer@groombook.dev" } ); const res = await app.request("/test"); expect(res.status).toBe(200); expect(capturedStaff).not.toBeNull(); expect(capturedStaff!.role).toBe("groomer"); expect(capturedStaff!.userId).toBe("ba-user-customer"); }); it("Better-Auth: returns 500 if INSERT yields no row", async () => { staffLookupResult = null; userLookupResult = { id: "ba-user-customer", name: "UAT Customer", email: "uat-customer@groombook.dev", }; insertReturningResult = null; // simulate INSERT … RETURNING returning [] const app = buildApp(resolveStaffMiddleware, undefined, { sub: "ba-user-customer", email: "uat-customer@groombook.dev", }); const res = await app.request("/test"); expect(res.status).toBe(500); const body = await res.json(); expect(body.error).toMatch(/auto-provision failed/i); }); it("Better-Auth branch runs before OIDC branch (does not require jwt.email)", async () => { // A Better-Auth user row alone is sufficient: jwt.email is intentionally // absent. The pre-GRO-2052 code only auto-provisioned inside `if (jwt.email)`. staffLookupResult = null; userLookupResult = { id: "ba-user-customer", name: "UAT Customer", email: "uat-customer@groombook.dev", }; insertReturningResult = PROVISIONED; const app = buildApp(resolveStaffMiddleware, undefined, { sub: "ba-user-customer", }); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("OIDC fallback: still provisions when user row is missing but account row exists", async () => { // No staff row, no Better-Auth user, but an OIDC account row. staffLookupResult = null; userLookupResult = null; accountLookupResult = { id: "oidc-account-id" }; insertReturningResult = { ...PROVISIONED, userId: "oidc-sub" }; const app = buildApp(resolveStaffMiddleware, undefined, { sub: "oidc-sub", email: "oidc-user@example.com", }); const res = await app.request("/test"); expect(res.status).toBe(200); }); it("falls through to 403 when neither Better-Auth user nor OIDC account row exists", async () => { staffLookupResult = null; userLookupResult = null; accountLookupResult = null; const app = buildApp(resolveStaffMiddleware, undefined, { sub: "ghost-sub", email: "ghost@example.com", }); const res = await app.request("/test"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/no staff record/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); }); });