From 4923606bb789644105de73eede7dc4dfa5b399d3 Mon Sep 17 00:00:00 2001 From: Groom Book CEO Date: Fri, 20 Mar 2026 02:09:41 +0000 Subject: [PATCH 1/4] feat: implement Staff Impersonation backend and wire frontend Add server-side impersonation session management with full audit logging, replacing the frontend-only mock. Managers can start time-limited sessions to view the app as a specific client. Backend: - Add impersonation_sessions and impersonation_audit_logs tables (Drizzle schema) with proper FK constraints and status enum - Add Hono API routes: start/get/extend/end session + audit logging - Server-side session expiration, one-active-per-staff enforcement - Staff role validation (manager-only) Frontend: - Add CustomerPortal wrapper with URL-param session init - Add ImpersonationBanner with live countdown timer - Add AuditLogViewer modal for session audit trail - Add "View as Customer" button on Clients page - Auto-log page visits during impersonation Closes #74 Co-Authored-By: Paperclip --- apps/api/src/index.ts | 2 + apps/api/src/routes/impersonation.ts | 269 ++++++++++++++++++++ apps/web/src/App.tsx | 3 + apps/web/src/pages/Clients.tsx | 8 + apps/web/src/portal/AuditLogViewer.tsx | 93 +++++++ apps/web/src/portal/CustomerPortal.tsx | 148 +++++++++++ apps/web/src/portal/ImpersonationBanner.tsx | 86 +++++++ packages/db/src/schema.ts | 34 +++ packages/types/src/index.ts | 25 ++ 9 files changed, 668 insertions(+) create mode 100644 apps/api/src/routes/impersonation.ts create mode 100644 apps/web/src/portal/AuditLogViewer.tsx create mode 100644 apps/web/src/portal/CustomerPortal.tsx create mode 100644 apps/web/src/portal/ImpersonationBanner.tsx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ed494a2..5403e02 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,6 +12,7 @@ import { bookRouter } from "./routes/book.js"; import { reportsRouter } from "./routes/reports.js"; import { appointmentGroupsRouter } from "./routes/appointmentGroups.js"; import { groomingLogsRouter } from "./routes/groomingLogs.js"; +import { impersonationRouter } from "./routes/impersonation.js"; import { authMiddleware } from "./middleware/auth.js"; import { startReminderScheduler } from "./services/reminders.js"; @@ -46,6 +47,7 @@ api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); api.route("/appointment-groups", appointmentGroupsRouter); api.route("/grooming-logs", groomingLogsRouter); +api.route("/impersonation", impersonationRouter); const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); diff --git a/apps/api/src/routes/impersonation.ts b/apps/api/src/routes/impersonation.ts new file mode 100644 index 0000000..b1572f4 --- /dev/null +++ b/apps/api/src/routes/impersonation.ts @@ -0,0 +1,269 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { + and, + eq, + getDb, + impersonationSessions, + impersonationAuditLogs, + staff, + clients, + desc, +} from "@groombook/db"; +import type { JwtPayload } from "../middleware/auth.js"; + +type Env = { Variables: { jwtPayload: JwtPayload } }; + +export const impersonationRouter = new Hono(); + +const SESSION_TIMEOUT_MINUTES = 30; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) { + return new Date(Date.now() + minutes * 60_000); +} + +/** Resolve the staff row for the authenticated OIDC subject. */ +async function resolveStaff(sub: string) { + const db = getDb(); + const [row] = await db + .select() + .from(staff) + .where(eq(staff.oidcSub, sub)); + return row ?? null; +} + +/** Expire any timed-out active sessions for a given staff member. */ +async function expireTimedOutSessions(staffId: string) { + const db = getDb(); + const now = new Date(); + const active = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.staffId, staffId), + eq(impersonationSessions.status, "active") + ) + ); + for (const s of active) { + if (s.expiresAt <= now) { + await db + .update(impersonationSessions) + .set({ status: "expired", endedAt: now }) + .where(eq(impersonationSessions.id, s.id)); + } + } +} + +// ─── POST / — Start a new impersonation session ───────────────────────────── + +const startSessionSchema = z.object({ + clientId: z.string().uuid(), + reason: z.string().max(500).optional(), +}); + +impersonationRouter.post( + "/sessions", + zValidator("json", startSessionSchema), + async (c) => { + const db = getDb(); + const jwt = c.get("jwtPayload") as JwtPayload; + const body = c.req.valid("json"); + + // Resolve authenticated staff + const staffRow = await resolveStaff(jwt.sub); + if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + if (staffRow.role !== "manager") { + return c.json({ error: "Only managers can impersonate clients" }, 403); + } + + // Verify client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.id, body.clientId)); + if (!client) return c.json({ error: "Client not found" }, 404); + + // Expire timed-out sessions first + await expireTimedOutSessions(staffRow.id); + + // Enforce one active session per staff member + const [existing] = await db + .select() + .from(impersonationSessions) + .where( + and( + eq(impersonationSessions.staffId, staffRow.id), + eq(impersonationSessions.status, "active") + ) + ); + if (existing) { + return c.json( + { error: "You already have an active impersonation session", sessionId: existing.id }, + 409 + ); + } + + const [session] = await db + .insert(impersonationSessions) + .values({ + staffId: staffRow.id, + clientId: body.clientId, + reason: body.reason ?? null, + expiresAt: expiresAt(), + }) + .returning(); + + // Log session start + await db.insert(impersonationAuditLogs).values({ + sessionId: session!.id, + action: "session_started", + metadata: { reason: body.reason ?? null }, + }); + + return c.json(session!, 201); + } +); + +// ─── GET /sessions/:id — Get session details ──────────────────────────────── + +impersonationRouter.get("/sessions/:id", async (c) => { + const db = getDb(); + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + return c.json(session); +}); + +// ─── POST /sessions/:id/extend — Extend session timeout ───────────────────── + +impersonationRouter.post("/sessions/:id/extend", async (c) => { + const db = getDb(); + const jwt = c.get("jwtPayload") as JwtPayload; + const staffRow = await resolveStaff(jwt.sub); + if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } + if (session.status !== "active") { + return c.json({ error: "Session is not active" }, 400); + } + + const newExpiry = expiresAt(); + const [updated] = await db + .update(impersonationSessions) + .set({ expiresAt: newExpiry }) + .where(eq(impersonationSessions.id, session.id)) + .returning(); + + await db.insert(impersonationAuditLogs).values({ + sessionId: session.id, + action: "session_extended", + metadata: { newExpiresAt: newExpiry.toISOString() }, + }); + + return c.json(updated); +}); + +// ─── POST /sessions/:id/end — End session ──────────────────────────────────── + +impersonationRouter.post("/sessions/:id/end", async (c) => { + const db = getDb(); + const jwt = c.get("jwtPayload") as JwtPayload; + const staffRow = await resolveStaff(jwt.sub); + if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } + if (session.status !== "active") { + return c.json({ error: "Session is not active" }, 400); + } + + const now = new Date(); + const [updated] = await db + .update(impersonationSessions) + .set({ status: "ended", endedAt: now }) + .where(eq(impersonationSessions.id, session.id)) + .returning(); + + await db.insert(impersonationAuditLogs).values({ + sessionId: session.id, + action: "session_ended", + }); + + return c.json(updated); +}); + +// ─── POST /sessions/:id/log — Log an audit entry ──────────────────────────── + +const logEntrySchema = z.object({ + action: z.string().min(1).max(200), + pageVisited: z.string().max(500).optional(), + metadata: z.record(z.unknown()).optional(), +}); + +impersonationRouter.post( + "/sessions/:id/log", + zValidator("json", logEntrySchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + if (session.status !== "active") { + return c.json({ error: "Session is not active" }, 400); + } + + const [entry] = await db + .insert(impersonationAuditLogs) + .values({ + sessionId: session.id, + action: body.action, + pageVisited: body.pageVisited ?? null, + metadata: body.metadata ?? null, + }) + .returning(); + + return c.json(entry, 201); + } +); + +// ─── GET /sessions/:id/audit-log — Get audit trail ────────────────────────── + +impersonationRouter.get("/sessions/:id/audit-log", async (c) => { + const db = getDb(); + const [session] = await db + .select() + .from(impersonationSessions) + .where(eq(impersonationSessions.id, c.req.param("id"))); + if (!session) return c.json({ error: "Session not found" }, 404); + + const logs = await db + .select() + .from(impersonationAuditLogs) + .where(eq(impersonationAuditLogs.sessionId, session.id)) + .orderBy(desc(impersonationAuditLogs.createdAt)); + + return c.json(logs); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4ba6a85..b2d13b8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,7 @@ import { InvoicesPage } from "./pages/Invoices.js"; import { BookPage } from "./pages/Book.js"; import { ReportsPage } from "./pages/Reports.js"; import { GroupBookingPage } from "./pages/GroupBooking.js"; +import { CustomerPortal } from "./portal/CustomerPortal.js"; const NAV_LINKS = [ { to: "/", label: "Appointments" }, @@ -21,6 +22,7 @@ const NAV_LINKS = [ export function App() { const location = useLocation(); return ( +
+
); } diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index e28d424..79ce2b9 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; // ─── Forms ─────────────────────────────────────────────────────────────────── @@ -41,6 +42,7 @@ const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: " // ─── Component ─────────────────────────────────────────────────────────────── export function ClientsPage() { + const navigate = useNavigate(); const [clients, setClients] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -360,6 +362,12 @@ export function ClientsPage() { )}
+ diff --git a/apps/web/src/portal/AuditLogViewer.tsx b/apps/web/src/portal/AuditLogViewer.tsx new file mode 100644 index 0000000..1693e24 --- /dev/null +++ b/apps/web/src/portal/AuditLogViewer.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import type { ImpersonationAuditLog } from "@groombook/types"; + +interface Props { + sessionId: string; + onClose: () => void; +} + +export function AuditLogViewer({ sessionId, onClose }: Props) { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch(`/api/impersonation/sessions/${sessionId}/audit-log`) + .then((r) => r.json()) + .then((data) => setLogs(data as ImpersonationAuditLog[])) + .finally(() => setLoading(false)); + }, [sessionId]); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+

Audit Log

+ +
+ + {loading ? ( +

Loading audit log...

+ ) : logs.length === 0 ? ( +

No audit entries.

+ ) : ( + + + + + + + + + + {logs.map((log) => ( + + + + + + ))} + +
TimeActionPage
+ {new Date(log.createdAt).toLocaleTimeString()} + {log.action} + {log.pageVisited ?? "—"} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx new file mode 100644 index 0000000..765e392 --- /dev/null +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams, useLocation } from "react-router-dom"; +import type { ImpersonationSession } from "@groombook/types"; +import { ImpersonationBanner } from "./ImpersonationBanner.js"; +import { AuditLogViewer } from "./AuditLogViewer.js"; + +interface Props { + children: React.ReactNode; +} + +/** + * Wraps the app to provide impersonation state. + * Start impersonation by navigating with ?impersonate=. + * The banner is non-dismissable while a session is active. + */ +export function CustomerPortal({ children }: Props) { + const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); + const [session, setSession] = useState(null); + const [clientName, setClientName] = useState(""); + const [showAuditLog, setShowAuditLog] = useState(false); + const [error, setError] = useState(null); + + // Start session from URL param + const impersonateClientId = searchParams.get("impersonate"); + + const startSession = useCallback( + async (clientId: string) => { + try { + const res = await fetch("/api/impersonation/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clientId }), + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string; sessionId?: string }; + if (res.status === 409 && err.sessionId) { + // Already have an active session — load it + const existing = await fetch(`/api/impersonation/sessions/${err.sessionId}`); + if (existing.ok) { + setSession((await existing.json()) as ImpersonationSession); + } + } else { + setError(err.error ?? `HTTP ${res.status}`); + } + return; + } + setSession((await res.json()) as ImpersonationSession); + } catch { + setError("Failed to start impersonation session"); + } + }, + [] + ); + + useEffect(() => { + if (impersonateClientId && !session) { + // Fetch client name + fetch(`/api/clients/${impersonateClientId}`) + .then((r) => r.json()) + .then((c: { name?: string }) => setClientName(c.name ?? "Unknown")) + .catch(() => setClientName("Unknown")); + void startSession(impersonateClientId); + // Clean the URL param + const next = new URLSearchParams(searchParams); + next.delete("impersonate"); + setSearchParams(next, { replace: true }); + } + }, [impersonateClientId, session, searchParams, setSearchParams, startSession]); + + // Log page visits + useEffect(() => { + if (!session || session.status !== "active") return; + void fetch(`/api/impersonation/sessions/${session.id}/log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "page_visit", pageVisited: location.pathname }), + }); + }, [location.pathname, session]); + + async function endSession() { + if (!session) return; + const res = await fetch(`/api/impersonation/sessions/${session.id}/end`, { + method: "POST", + }); + if (res.ok) { + setSession(null); + setClientName(""); + } + } + + async function extendSession() { + if (!session) return; + const res = await fetch(`/api/impersonation/sessions/${session.id}/extend`, { + method: "POST", + }); + if (res.ok) { + setSession((await res.json()) as ImpersonationSession); + } + } + + return ( + <> + {error && ( +
+ {error} + +
+ )} + + {session && session.status === "active" && ( + + )} + + {/* Push content down when banner is visible */} +
+ {children} +
+ + {showAuditLog && session && ( + setShowAuditLog(false)} /> + )} + + ); +} diff --git a/apps/web/src/portal/ImpersonationBanner.tsx b/apps/web/src/portal/ImpersonationBanner.tsx new file mode 100644 index 0000000..9fa2acf --- /dev/null +++ b/apps/web/src/portal/ImpersonationBanner.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; + +interface Props { + clientName: string; + expiresAt: string; + onEnd: () => void; + onExtend: () => void; +} + +export function ImpersonationBanner({ clientName, expiresAt, onEnd, onExtend }: Props) { + const [remaining, setRemaining] = useState(""); + + useEffect(() => { + function tick() { + const diff = new Date(expiresAt).getTime() - Date.now(); + if (diff <= 0) { + setRemaining("Expired"); + return; + } + const mins = Math.floor(diff / 60_000); + const secs = Math.floor((diff % 60_000) / 1000); + setRemaining(`${mins}:${secs.toString().padStart(2, "0")}`); + } + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [expiresAt]); + + return ( +
+
+ IMPERSONATING: {clientName} — Read-only mode + + Time remaining: {remaining} + +
+
+ + +
+
+ ); +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 8987c2a..fd97b3f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -218,6 +218,40 @@ export const reminderLogs = pgTable( (t) => [unique().on(t.appointmentId, t.reminderType)] ); +// ─── Impersonation ────────────────────────────────────────────────────────── + +export const impersonationSessionStatusEnum = pgEnum( + "impersonation_session_status", + ["active", "ended", "expired"] +); + +export const impersonationSessions = pgTable("impersonation_sessions", { + id: uuid("id").primaryKey().defaultRandom(), + staffId: uuid("staff_id") + .notNull() + .references(() => staff.id, { onDelete: "restrict" }), + clientId: uuid("client_id") + .notNull() + .references(() => clients.id, { onDelete: "restrict" }), + reason: text("reason"), + status: impersonationSessionStatusEnum("status").notNull().default("active"), + startedAt: timestamp("started_at").notNull().defaultNow(), + endedAt: timestamp("ended_at"), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + +export const impersonationAuditLogs = pgTable("impersonation_audit_logs", { + id: uuid("id").primaryKey().defaultRandom(), + sessionId: uuid("session_id") + .notNull() + .references(() => impersonationSessions.id, { onDelete: "cascade" }), + action: text("action").notNull(), + pageVisited: text("page_visited"), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + export const groomingVisitLogs = pgTable("grooming_visit_logs", { id: uuid("id").primaryKey().defaultRandom(), petId: uuid("pet_id") diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index dae5721..6d13d4f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -145,6 +145,31 @@ export interface Invoice { tipSplits?: InvoiceTipSplit[]; } +// ─── Impersonation ────────────────────────────────────────────────────────── + +export type ImpersonationSessionStatus = "active" | "ended" | "expired"; + +export interface ImpersonationSession { + id: string; + staffId: string; + clientId: string; + reason: string | null; + status: ImpersonationSessionStatus; + startedAt: string; + endedAt: string | null; + expiresAt: string; + createdAt: string; +} + +export interface ImpersonationAuditLog { + id: string; + sessionId: string; + action: string; + pageVisited: string | null; + metadata: Record | null; + createdAt: string; +} + // Paginated list response export interface PaginatedList { items: T[]; -- 2.52.0 From 467c428c4c281ee792f4f3e077315ff414c786a9 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Fri, 20 Mar 2026 02:17:50 +0000 Subject: [PATCH 2/4] chore: remove unused useNavigate import from Clients.tsx Co-Authored-By: Claude Opus 4.6 --- apps/web/src/pages/Clients.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index c46d7f3..50afed9 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; import type { Client, GroomingVisitLog, Pet } from "@groombook/types"; // ─── Forms ─────────────────────────────────────────────────────────────────── @@ -42,7 +41,6 @@ const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: " // ─── Component ─────────────────────────────────────────────────────────────── export function ClientsPage() { - const navigate = useNavigate(); const [clients, setClients] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); -- 2.52.0 From 6893cad13a5032ec0622871f1dc84de25a283f90 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Fri, 20 Mar 2026 02:35:30 +0000 Subject: [PATCH 3/4] fix: add authorization + expiry checks to impersonation endpoints, add tests Security: Add ownership verification (resolveStaff + staffId check) to GET /sessions/:id, POST /sessions/:id/log, and GET /sessions/:id/audit-log endpoints that were previously unprotected. Bug: Add time-based expiry checks to extend, end, get-session, and log endpoints via checkAndExpireSession() helper. Expired sessions are now auto-marked as expired in the DB and cannot be extended or logged to. Tests: Add 23 tests covering session creation (happy path, auth, conflict), extend (active, expired, non-owner, ended), end (active, expired, non-owner), audit logging (owner, non-owner, expired, ended), and audit-log retrieval (owner, non-owner, not found). Addresses QA review on PR #75 (GRO-66). Co-Authored-By: Claude Opus 4.6 --- apps/api/src/__tests__/impersonation.test.ts | 577 +++++++++++++++++++ apps/api/src/routes/impersonation.ts | 61 ++ 2 files changed, 638 insertions(+) create mode 100644 apps/api/src/__tests__/impersonation.test.ts diff --git a/apps/api/src/__tests__/impersonation.test.ts b/apps/api/src/__tests__/impersonation.test.ts new file mode 100644 index 0000000..8eb6217 --- /dev/null +++ b/apps/api/src/__tests__/impersonation.test.ts @@ -0,0 +1,577 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import type { JwtPayload } from "../middleware/auth.js"; + +// ─── Mock data ─────────────────────────────────────────────────────────────── + +const MANAGER_STAFF = { + id: "staff-manager-id", + oidcSub: "oidc-manager-sub", + role: "manager", + name: "Manager", +}; + +const GROOMER_STAFF = { + 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"); + +function createApp(sub: string) { + const app = new Hono<{ Variables: { jwtPayload: JwtPayload } }>(); + app.use("*", async (c, next) => { + c.set("jwtPayload", { sub } as JwtPayload); + await next(); + }); + 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("oidc-manager-sub"); + selectQueue.push( + [MANAGER_STAFF], // resolveStaff + [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", async () => { + const app = createApp("oidc-groomer-sub"); + selectQueue.push([GROOMER_STAFF]); + + 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(/only managers/i); + }); + + it("returns 403 when staff record not found", async () => { + const app = createApp("unknown-sub"); + selectQueue.push([]); + + 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("oidc-manager-sub"); + selectQueue.push( + [MANAGER_STAFF], // resolveStaff + [] // 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("oidc-manager-sub"); + const existing = makeSession(); + selectQueue.push( + [MANAGER_STAFF], // resolveStaff + [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("oidc-manager-sub"); + const session = makeSession(); + selectQueue.push( + [MANAGER_STAFF], // resolveStaff + [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("oidc-groomer-sub"); + const session = makeSession(); // owned by manager + selectQueue.push( + [GROOMER_STAFF], // resolveStaff + [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("oidc-manager-sub"); + selectQueue.push( + [MANAGER_STAFF], // resolveStaff + [] // 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("oidc-manager-sub"); + const session = makeSession({ expiresAt: pastDate() }); + selectQueue.push( + [MANAGER_STAFF], // resolveStaff + [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("oidc-manager-sub"); + const session = makeSession(); + selectQueue.push( + [MANAGER_STAFF], // resolveStaff + [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("oidc-manager-sub"); + const session = makeSession({ expiresAt: pastDate() }); + selectQueue.push( + [MANAGER_STAFF], // resolveStaff + [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("oidc-groomer-sub"); + const session = makeSession(); + selectQueue.push( + [GROOMER_STAFF], // resolveStaff + [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("oidc-manager-sub"); + const session = makeSession({ status: "ended" }); + selectQueue.push( + [MANAGER_STAFF], + [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("oidc-manager-sub"); + const session = makeSession(); + selectQueue.push( + [MANAGER_STAFF], + [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("oidc-manager-sub"); + const session = makeSession({ expiresAt: pastDate() }); + selectQueue.push( + [MANAGER_STAFF], + [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("oidc-groomer-sub"); + const session = makeSession(); + selectQueue.push( + [GROOMER_STAFF], + [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("oidc-manager-sub"); + const session = makeSession(); + selectQueue.push( + [MANAGER_STAFF], + [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("oidc-groomer-sub"); + const session = makeSession(); + selectQueue.push( + [GROOMER_STAFF], + [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("oidc-manager-sub"); + const session = makeSession({ expiresAt: pastDate() }); + selectQueue.push( + [MANAGER_STAFF], + [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("oidc-manager-sub"); + const session = makeSession({ status: "ended" }); + selectQueue.push( + [MANAGER_STAFF], + [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("oidc-manager-sub"); + const session = makeSession(); + const logs = [makeAuditLog(), makeAuditLog({ id: "audit-uuid-2", action: "page_visit" })]; + selectQueue.push( + [MANAGER_STAFF], // resolveStaff + [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("oidc-groomer-sub"); + const session = makeSession(); + selectQueue.push( + [GROOMER_STAFF], + [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("oidc-manager-sub"); + selectQueue.push( + [MANAGER_STAFF], + [] + ); + + const res = await app.request( + "/impersonation/sessions/nonexistent/audit-log" + ); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/api/src/routes/impersonation.ts b/apps/api/src/routes/impersonation.ts index b1572f4..d7a627e 100644 --- a/apps/api/src/routes/impersonation.ts +++ b/apps/api/src/routes/impersonation.ts @@ -58,6 +58,24 @@ async function expireTimedOutSessions(staffId: string) { } } +/** + * Check if an active session has expired by time. If so, mark it expired in DB + * and return true. Returns false if the session is still valid. + */ +async function checkAndExpireSession( + session: typeof impersonationSessions.$inferSelect +): Promise { + if (session.status !== "active") return false; + if (session.expiresAt > new Date()) return false; + const db = getDb(); + const now = new Date(); + await db + .update(impersonationSessions) + .set({ status: "expired", endedAt: now }) + .where(eq(impersonationSessions.id, session.id)); + return true; +} + // ─── POST / — Start a new impersonation session ───────────────────────────── const startSessionSchema = z.object({ @@ -132,11 +150,25 @@ impersonationRouter.post( impersonationRouter.get("/sessions/:id", async (c) => { const db = getDb(); + const jwt = c.get("jwtPayload") as JwtPayload; + const staffRow = await resolveStaff(jwt.sub); + if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + const [session] = await db .select() .from(impersonationSessions) .where(eq(impersonationSessions.id, c.req.param("id"))); if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } + + // Auto-expire if timed out + if (await checkAndExpireSession(session)) { + session.status = "expired"; + session.endedAt = new Date(); + } + return c.json(session); }); @@ -160,6 +192,11 @@ impersonationRouter.post("/sessions/:id/extend", async (c) => { return c.json({ error: "Session is not active" }, 400); } + // Check time-based expiry + if (await checkAndExpireSession(session)) { + return c.json({ error: "Session has expired" }, 400); + } + const newExpiry = expiresAt(); const [updated] = await db .update(impersonationSessions) @@ -196,6 +233,11 @@ impersonationRouter.post("/sessions/:id/end", async (c) => { return c.json({ error: "Session is not active" }, 400); } + // Check time-based expiry + if (await checkAndExpireSession(session)) { + return c.json({ error: "Session has expired" }, 400); + } + const now = new Date(); const [updated] = await db .update(impersonationSessions) @@ -224,17 +266,29 @@ impersonationRouter.post( zValidator("json", logEntrySchema), async (c) => { const db = getDb(); + const jwt = c.get("jwtPayload") as JwtPayload; const body = c.req.valid("json"); + const staffRow = await resolveStaff(jwt.sub); + if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + const [session] = await db .select() .from(impersonationSessions) .where(eq(impersonationSessions.id, c.req.param("id"))); if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } if (session.status !== "active") { return c.json({ error: "Session is not active" }, 400); } + // Check time-based expiry + if (await checkAndExpireSession(session)) { + return c.json({ error: "Session has expired" }, 400); + } + const [entry] = await db .insert(impersonationAuditLogs) .values({ @@ -253,11 +307,18 @@ impersonationRouter.post( impersonationRouter.get("/sessions/:id/audit-log", async (c) => { const db = getDb(); + const jwt = c.get("jwtPayload") as JwtPayload; + const staffRow = await resolveStaff(jwt.sub); + if (!staffRow) return c.json({ error: "Staff record not found" }, 403); + const [session] = await db .select() .from(impersonationSessions) .where(eq(impersonationSessions.id, c.req.param("id"))); if (!session) return c.json({ error: "Session not found" }, 404); + if (session.staffId !== staffRow.id) { + return c.json({ error: "Not your session" }, 403); + } const logs = await db .select() -- 2.52.0 From 5e1207338ca356fb948aff26ff7ea4cf1b4bff65 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Fri, 20 Mar 2026 02:37:06 +0000 Subject: [PATCH 4/4] fix: resolve @groombook/db source in vitest config Add resolve alias so vitest can resolve @groombook/db from source TypeScript files without requiring a prior build step. Fixes CI test failures when dist/ has not been compiled. Co-Authored-By: Claude Opus 4.6 --- apps/api/vitest.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 2db11ce..0303dd8 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; +import path from "path"; export default defineConfig({ + resolve: { + alias: { + "@groombook/db": path.resolve(__dirname, "../../packages/db/src/index.ts"), + }, + }, test: { coverage: { provider: "v8", -- 2.52.0