import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; import type { AppEnv, StaffRow } from "../middleware/rbac.js"; import { buildStaff } from "@groombook/db/factories"; // ─── Mock data (built with factories for schema-safe defaults) ──────────────── const MANAGER_STAFF = buildStaff({ id: "staff-manager-id", oidcSub: "oidc-manager-sub", role: "manager", name: "Manager" }); const GROOMER_STAFF = buildStaff({ id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", name: "Groomer" }); const CLIENT = { id: "aabbccdd-1111-2222-3333-444444444444", name: "Fido Owner" }; const futureDate = () => new Date(Date.now() + 30 * 60_000); const pastDate = () => new Date(Date.now() - 5 * 60_000); function makeSession(overrides: Record = {}) { return { id: "session-uuid-1", staffId: MANAGER_STAFF.id, clientId: CLIENT.id, reason: "Testing portal", status: "active" as string, startedAt: new Date(), endedAt: null as Date | null, expiresAt: futureDate(), createdAt: new Date(), ...overrides, }; } function makeAuditLog(overrides: Record = {}) { return { id: "audit-uuid-1", sessionId: "session-uuid-1", action: "session_started", pageVisited: null, metadata: null, createdAt: new Date(), ...overrides, }; } // ─── Queue-based mock DB ───────────────────────────────────────────────────── let selectQueue: unknown[][] = []; let insertedValues: Array<{ table: string; vals: unknown }> = []; let updatedValues: Array<{ table: string; set: Record }> = []; function resetMock() { selectQueue = []; insertedValues = []; updatedValues = []; } /** * Returns a chainable object that acts like a drizzle query result. * Any method call (.where, .orderBy, .limit) returns the same chainable, * but the FIRST terminal call (.where or .orderBy when no further chain) * resolves the result from the queue. * * To handle `.where().orderBy()` chaining, we make the result of shifting * also have .orderBy/.limit methods, and we wrap the shifted array in a proxy. */ function makeChainableResult(data: unknown[]): unknown { // Make data act both as array and as chainable const arr = [...data]; return new Proxy(arr, { get(target, prop) { if (prop === "orderBy" || prop === "limit") { // Further chaining just returns the same data return () => makeChainableResult(data); } // @ts-expect-error proxy access return target[prop]; }, }); } vi.mock("@groombook/db", () => { function makeTable(name: string) { return new Proxy( { _name: name }, { get(target, prop) { if (prop === "_name") return name; if (prop === "$inferSelect") return {}; return { table: name, column: prop }; }, } ); } return { getDb: () => ({ select: () => ({ from: () => ({ where: () => { const data = selectQueue.shift() ?? []; return makeChainableResult(data); }, orderBy: () => { const data = selectQueue.shift() ?? []; return makeChainableResult(data); }, limit: () => { const data = selectQueue.shift() ?? []; return makeChainableResult(data); }, }), }), insert: (table: { _name: string }) => ({ values: (vals: unknown) => { const tableName = table?._name ?? "unknown"; insertedValues.push({ table: tableName, vals }); return { returning: () => { if (tableName === "sessions") { return [makeSession(vals as Record)]; } return [makeAuditLog(vals as Record)]; }, }; }, }), update: (table: { _name: string }) => ({ set: (data: Record) => ({ where: () => { const tableName = table?._name ?? "unknown"; updatedValues.push({ table: tableName, set: data }); return { returning: () => { const base = makeSession(); return [{ ...base, ...data }]; }, }; }, }), }), }), staff: makeTable("staff"), clients: makeTable("clients"), impersonationSessions: makeTable("sessions"), impersonationAuditLogs: makeTable("auditLogs"), eq: vi.fn(), and: vi.fn(), desc: vi.fn(), }; }); // ─── App setup ─────────────────────────────────────────────────────────────── const { impersonationRouter } = await import("../routes/impersonation.js"); const { requireRole } = await import("../middleware/rbac.js"); /** * Build a test app. If staffRow is null the middleware simulates * resolveStaffMiddleware returning 403 (staff not found). An optional * roleGuard applies requireRole(...roles) before the router. */ function createApp( staffRow: (typeof MANAGER_STAFF) | null, roleGuard?: string[] ) { const app = new Hono(); app.use("*", async (c, next) => { if (!staffRow) { return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403); } c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string }); c.set("staff", staffRow as unknown as StaffRow); await next(); }); if (roleGuard && roleGuard.length > 0) { app.use("*", requireRole(...(roleGuard as Parameters)) as never); } app.route("/impersonation", impersonationRouter); return app; } function jsonPost(path: string, body: unknown) { return { method: "POST" as const, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }; } // ─── Tests ─────────────────────────────────────────────────────────────────── beforeEach(() => resetMock()); // ─── POST /sessions — Create session ───────────────────────────────────────── describe("POST /impersonation/sessions", () => { it("creates a session for a manager", async () => { const app = createApp(MANAGER_STAFF, ["manager"]); selectQueue.push( [CLIENT], // client lookup [], // expireTimedOutSessions active query [] // existing active check ); const res = await app.request( "/impersonation/sessions", jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) ); expect(res.status).toBe(201); expect(insertedValues.some((v) => v.table === "sessions")).toBe(true); expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true); }); it("rejects non-managers via requireRole guard", async () => { const app = createApp(GROOMER_STAFF, ["manager"]); const res = await app.request( "/impersonation/sessions", jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) ); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/forbidden/i); }); it("returns 403 when staff record not found", async () => { const app = createApp(null); const res = await app.request( "/impersonation/sessions", jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) ); expect(res.status).toBe(403); }); it("returns 404 when client not found", async () => { const app = createApp(MANAGER_STAFF, ["manager"]); selectQueue.push( [] // client not found ); const res = await app.request( "/impersonation/sessions", jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) ); expect(res.status).toBe(404); }); it("returns 409 when active session already exists", async () => { const app = createApp(MANAGER_STAFF, ["manager"]); const existing = makeSession(); selectQueue.push( [CLIENT], // client lookup [], // expireTimedOutSessions [existing] // existing active session ); const res = await app.request( "/impersonation/sessions", jsonPost("/impersonation/sessions", { clientId: CLIENT.id }) ); expect(res.status).toBe(409); const body = await res.json(); expect(body.error).toMatch(/already have an active/i); }); }); // ─── GET /sessions/:id — Authorization ─────────────────────────────────────── describe("GET /impersonation/sessions/:id", () => { it("returns session for the owning staff member", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession(); selectQueue.push( [session] // session lookup ); const res = await app.request("/impersonation/sessions/session-uuid-1"); expect(res.status).toBe(200); }); it("returns 403 for a different staff member", async () => { const app = createApp(GROOMER_STAFF); const session = makeSession(); // owned by manager selectQueue.push( [session] // session lookup ); const res = await app.request("/impersonation/sessions/session-uuid-1"); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/not your session/i); }); it("returns 404 for nonexistent session", async () => { const app = createApp(MANAGER_STAFF); selectQueue.push( [] // no session ); const res = await app.request("/impersonation/sessions/nonexistent"); expect(res.status).toBe(404); }); it("auto-expires a timed-out session", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession({ expiresAt: pastDate() }); selectQueue.push( [session] // session lookup ); const res = await app.request("/impersonation/sessions/session-uuid-1"); expect(res.status).toBe(200); const body = await res.json(); expect(body.status).toBe("expired"); // Should have called update to mark expired expect(updatedValues).toHaveLength(1); expect(updatedValues[0]!.set.status).toBe("expired"); }); }); // ─── POST /sessions/:id/extend ─────────────────────────────────────────────── describe("POST /impersonation/sessions/:id/extend", () => { it("extends an active non-expired session", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession(); selectQueue.push( [session] // session lookup ); const res = await app.request( "/impersonation/sessions/session-uuid-1/extend", { method: "POST" } ); expect(res.status).toBe(200); // Should have extended (updated expiresAt) and logged expect(updatedValues).toHaveLength(1); expect(insertedValues.some((v) => { const vals = v.vals as Record; return vals.action === "session_extended"; })).toBe(true); }); it("returns 400 when extending a time-expired session", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession({ expiresAt: pastDate() }); selectQueue.push( [session] // session lookup ); const res = await app.request( "/impersonation/sessions/session-uuid-1/extend", { method: "POST" } ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/expired/i); }); it("returns 403 for non-owner", async () => { const app = createApp(GROOMER_STAFF); const session = makeSession(); selectQueue.push( [session] // owned by manager ); const res = await app.request( "/impersonation/sessions/session-uuid-1/extend", { method: "POST" } ); expect(res.status).toBe(403); }); it("returns 400 for an ended session", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession({ status: "ended" }); selectQueue.push( [session] ); const res = await app.request( "/impersonation/sessions/session-uuid-1/extend", { method: "POST" } ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/not active/i); }); }); // ─── POST /sessions/:id/end ────────────────────────────────────────────────── describe("POST /impersonation/sessions/:id/end", () => { it("ends an active non-expired session", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession(); selectQueue.push( [session] ); const res = await app.request( "/impersonation/sessions/session-uuid-1/end", { method: "POST" } ); expect(res.status).toBe(200); expect(updatedValues).toHaveLength(1); expect(updatedValues[0]!.set.status).toBe("ended"); }); it("returns 400 when ending a time-expired session", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession({ expiresAt: pastDate() }); selectQueue.push( [session] ); const res = await app.request( "/impersonation/sessions/session-uuid-1/end", { method: "POST" } ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/expired/i); }); it("returns 403 for non-owner", async () => { const app = createApp(GROOMER_STAFF); const session = makeSession(); selectQueue.push( [session] ); const res = await app.request( "/impersonation/sessions/session-uuid-1/end", { method: "POST" } ); expect(res.status).toBe(403); }); }); // ─── POST /sessions/:id/log — Authorization + expiry ───────────────────────── describe("POST /impersonation/sessions/:id/log", () => { const logBody = { action: "page_visit", pageVisited: "/dashboard" }; it("logs an audit entry for the session owner", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession(); selectQueue.push( [session] ); const res = await app.request( "/impersonation/sessions/session-uuid-1/log", jsonPost("/", logBody) ); expect(res.status).toBe(201); expect(insertedValues.some((v) => v.table === "auditLogs")).toBe(true); }); it("returns 403 for non-owner", async () => { const app = createApp(GROOMER_STAFF); const session = makeSession(); selectQueue.push( [session] ); const res = await app.request( "/impersonation/sessions/session-uuid-1/log", jsonPost("/", logBody) ); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/not your session/i); }); it("returns 400 when session has expired by time", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession({ expiresAt: pastDate() }); selectQueue.push( [session] ); const res = await app.request( "/impersonation/sessions/session-uuid-1/log", jsonPost("/", logBody) ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/expired/i); }); it("returns 400 for an ended session", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession({ status: "ended" }); selectQueue.push( [session] ); const res = await app.request( "/impersonation/sessions/session-uuid-1/log", jsonPost("/", logBody) ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/not active/i); }); }); // ─── GET /sessions/:id/audit-log — Authorization ──────────────────────────── describe("GET /impersonation/sessions/:id/audit-log", () => { it("returns audit logs for the session owner", async () => { const app = createApp(MANAGER_STAFF); const session = makeSession(); const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })]; selectQueue.push( [session], // session lookup logs // audit logs query (where + orderBy chain) ); const res = await app.request( "/impersonation/sessions/session-uuid-1/audit-log" ); expect(res.status).toBe(200); const body = await res.json(); expect(body).toHaveLength(2); }); it("returns 403 for non-owner", async () => { const app = createApp(GROOMER_STAFF); const session = makeSession(); selectQueue.push( [session] ); const res = await app.request( "/impersonation/sessions/session-uuid-1/audit-log" ); expect(res.status).toBe(403); const body = await res.json(); expect(body.error).toMatch(/not your session/i); }); it("returns 404 for nonexistent session", async () => { const app = createApp(MANAGER_STAFF); selectQueue.push( [] ); const res = await app.request( "/impersonation/sessions/nonexistent/audit-log" ); expect(res.status).toBe(404); }); });