/** * Pet Profile Summary Tests * * Covers GET /api/pets/:id/profile-summary in the deployed tree (root src/). * * Two suites share one mock harness: * * 1. GRO-2013 owner-bypass (the deployed-tree port of #135): * A customer who is auto-provisioned as a `groomer` staff row by rbac.ts * (with no appointment linkage) may still read their own pet's summary * when they supply a valid X-Impersonation-Session-Id whose clientId * matches the pet's clientId. * * 2. GRO-2014 error handling (deployed tree): * - Empty-body 500 must never escape the route — the onError handler * converts unhandled errors into a structured JSON 500. * - Malformed UUIDs must return 404 (not 500 via a Postgres uuid cast). * - Missing staff context must return 401 (not TypeError on staffRow.id). * - Pet not found must return 404. * - Groomer with no appointment linkage must return 403. * - Manager and groomer with linkage must receive the summary body. * * Deployed tree handler: src/routes/pets.ts. The mock queries the * `appointments` table (the live schema) for visit history, not * `groomingVisitLogs`. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; import type { AppEnv, StaffRow } from "../middleware/rbac.js"; // ─── Staff fixtures ────────────────────────────────────────────────────────── const MANAGER: StaffRow = { id: "staff-manager-id", oidcSub: "oidc-manager-sub", userId: null, role: "manager", isSuperUser: true, name: "Manager McManager", email: "manager@example.com", active: true, icalToken: null, createdAt: new Date(), updatedAt: new Date(), }; const GROOMER: StaffRow = { ...MANAGER, id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", isSuperUser: false, name: "Groomer Gary", email: "groomer@example.com", }; /** * Mirrors the auto-provisioned "groomer" staff row rbac.ts creates for an * OIDC user (e.g. uat-customer@groombook.dev) on first login: role=groomer, * no appointment linkage. */ const CUSTOMER_STAFF: StaffRow = { ...MANAGER, id: "staff-customer-id", oidcSub: null, userId: "user-customer-id", role: "groomer", name: "UAT Customer", email: "uat-customer@groombook.dev", }; // ─── Mutable mock state ───────────────────────────────────────────────────── const CLIENT_ID = "c0000001-0000-0000-0000-000000000001"; const PET_ID = "c0000001-0000-0000-0000-000000000002"; const OTHER_CLIENT_PET_ID = "c0000002-0000-0000-0000-000000000099"; const UNKNOWN_PET_UUID = "00000000-0000-0000-0000-000000000001"; const futureDate = () => new Date(Date.now() + 30 * 60_000); const pastDate = () => new Date(Date.now() - 5 * 60_000); function makePet(overrides: Record = {}) { return { id: PET_ID, clientId: CLIENT_ID, name: "Biscuit", species: "dog", breed: "Golden Retriever", weightKg: "30.00", dateOfBirth: null, healthAlerts: null, groomingNotes: null, cutStyle: null, shampooPreference: null, specialCareNotes: null, customFields: {}, petSizeCategory: "large", coatType: "double", photoKey: null, photoUploadedAt: null, createdAt: new Date("2024-01-01"), updatedAt: new Date("2024-01-01"), ...overrides, }; } function makeAppointment(overrides: Record = {}) { return { id: "appt-1", clientId: CLIENT_ID, petId: PET_ID, serviceId: "service-1", staffId: GROOMER.id, batherStaffId: null, status: "completed", startTime: new Date("2024-06-01T09:00:00Z"), endTime: new Date("2024-06-01T11:00:00Z"), notes: null, priceCents: 6000, seriesId: null, seriesIndex: null, groupId: null, confirmationStatus: "confirmed", confirmedAt: null, cancelledAt: null, confirmationToken: null, customerNotes: null, createdAt: new Date("2024-05-15"), updatedAt: new Date("2024-05-15"), ...overrides, }; } function makeSession(overrides: Record = {}) { return { id: "sess-owner", staffId: CUSTOMER_STAFF.id, clientId: CLIENT_ID, reason: "sso-bridge", status: "active", startedAt: new Date(), endedAt: null, expiresAt: futureDate(), createdAt: new Date(), ...overrides, }; } // ─── DB mock state ────────────────────────────────────────────────────────── let petsTable: Record[]; let appointmentsTable: Record[]; let sessionsTable: Record[]; // selectQueue: queries resolve in FIFO order. Each .from(table) result // returns a chain that resolves to the next queued row set on a terminal // call (.where()/.orderBy()/.limit()). // // A queued entry of `{ table: "pets", rows: null, throw: "..." }` tells the // mock to throw instead of returning rows — used by the GRO-2014 "JSON // envelope on downstream error" test. Any other queued entry with `rows` // resolves to those rows. An entry with `rows: []` returns an empty array // (no rows, no throw). let selectQueue: Array<{ table: string; rows: unknown[] | null; throw?: string; }> = []; // Captured `db.insert(table).values(vals)` calls. Mirrors the pattern from // src/__tests__/impersonation.test.ts so the GRO-2063 audit row assertions // can inspect what the route tried to write without needing a real DB. let insertCapture: Array<{ table: string; vals: Record }> = []; function enqueue(table: string, rows: unknown[] = []) { selectQueue.push({ table, rows }); } function enqueueThrow(table: string, message: string) { selectQueue.push({ table, rows: null, throw: message }); } function resetMock() { petsTable = [makePet()]; appointmentsTable = [makeAppointment()]; sessionsTable = [makeSession()]; selectQueue = []; insertCapture = []; } // ─── Module mocks ─────────────────────────────────────────────────────────── 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 }; }, } ); } function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) { const queryString = _strings[0]; return { queryChunks: [queryString], as: (alias: string) => ({ queryChunks: [queryString], fieldAlias: alias, getSQL() { return this.queryChunks; }, }), }; } function takeQueuedRows(tableName: string): unknown[] { const next = selectQueue.shift(); if (next && next.table === tableName) { if (next.throw) { throw new Error(next.throw); } return next.rows ?? []; } return []; } // Wrap a finalised result in a Proxy that exposes chainable methods // and the resolved rows. Each call to a chainable method (where/orderBy/ // limit/...) returns the SAME rows so the route's natural await on the // chain resolves to the queued data. function wrapRows(rows: unknown[]): unknown { return new Proxy(rows, { get(target, prop: string | symbol) { if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin" || prop === "innerJoin" || prop === "from") { return () => wrapRows(rows); } if (prop === "then") { return (onFulfilled?: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) => Promise.resolve(rows).then(onFulfilled, onRejected); } if (prop === Symbol.iterator) { return function* () { for (const v of target) yield v; }; } if (prop === Symbol.asyncIterator) { return async function* () { for (const v of target) yield v; }; } // @ts-expect-error proxy access return target[prop]; }, }); } return { getDb: () => ({ select: (_cols?: Record) => ({ from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")), }), insert: (table: { _name?: string }) => ({ values: (vals: Record) => { insertCapture.push({ table: table._name ?? "unknown", vals }); return { returning: () => [{}] }; }, }), update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }), delete: () => ({ where: () => ({ returning: () => [{}] }) }), }), pets: makeTable("pets"), appointments: makeTable("appointments"), staff: makeTable("staff"), services: makeTable("services"), impersonationSessions: makeTable("impersonationSessions"), impersonationAuditLogs: makeTable("impersonation_audit_logs"), and: vi.fn((..._args: unknown[]) => ({})), desc: vi.fn((c: unknown) => c), eq: vi.fn((_a: unknown, _b: unknown) => ({})), exists: vi.fn(() => true), or: vi.fn((..._args: unknown[]) => ({})), sql: sqlMock, }; }); vi.mock("../lib/s3.js", () => ({ getPresignedUploadUrl: vi.fn(), getPresignedGetUrl: vi.fn(), deleteObject: vi.fn(), })); // ─── Import after mocks are set up ────────────────────────────────────────── const { petsRouter } = await import("../routes/pets.js"); // ─── App builder ──────────────────────────────────────────────────────────── function buildApp(staffRow: StaffRow | null) { const app = new Hono(); app.use("*", async (c, next) => { if (staffRow) { c.set("jwtPayload", { sub: staffRow.oidcSub ?? staffRow.userId ?? "" }); c.set("staff", staffRow); } await next(); }); app.route("/pets", petsRouter); return app; } // ─── Reset before each test ───────────────────────────────────────────────── beforeEach(() => { resetMock(); vi.clearAllMocks(); }); // ─── GRO-2014 error-handling suite ────────────────────────────────────────── describe("GET /:id/profile-summary — GRO-2014 error handling", () => { it("returns 404 (not 500) for a malformed UUID path param", async () => { const app = buildApp(MANAGER); const res = await app.request("/pets/not-a-uuid/profile-summary"); expect(res.status).toBe(404); const body = (await res.json()) as { error: string }; expect(body.error).toBe("Not found"); }); it("returns 401 when staff context is missing (defense in depth)", async () => { const app = buildApp(null); const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`); expect(res.status).toBe(401); const body = (await res.json()) as { error: string }; expect(body.error).toBe("Unauthorized"); }); it("returns 404 when authenticated and pet does not exist", async () => { enqueue("pets", []); const app = buildApp(MANAGER); const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`); expect(res.status).toBe(404); const body = (await res.json()) as { error: string }; expect(body.error).toBe("Not found"); }); it("returns 403 when groomer has no appointment linkage to the pet's client", async () => { enqueue("pets", petsTable); enqueue("appointments", []); // linkage check returns empty → 403 const app = buildApp(GROOMER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(403); const body = (await res.json()) as { error: string }; expect(body.error).toBe("Forbidden"); }); it("returns 200 with summary for a manager (no groomer linkage check)", async () => { enqueue("pets", petsTable); enqueue("appointments", appointmentsTable); // history enqueue("appointments", [{ count: 1 }]); // visit count enqueue("appointments", []); // upcoming (none) const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.id).toBe(PET_ID); expect(body.name).toBe("Biscuit"); expect(body.visitCount).toBe(1); expect(body.upcomingAppointment).toBeNull(); expect(body.recentGroomingHistory).toBeInstanceOf(Array); }); it("returns 200 with summary for a groomer with appointment linkage", async () => { enqueue("pets", petsTable); enqueue("appointments", [{ id: "appt-1" }]); // linkage found enqueue("appointments", appointmentsTable); // history enqueue("appointments", [{ count: 1 }]); // visit count enqueue("appointments", []); // upcoming const app = buildApp(GROOMER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.id).toBe(PET_ID); }); it("returns a JSON envelope (not empty body) when a downstream query throws", async () => { enqueueThrow("pets", "simulated postgres uuid cast failure"); const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(500); const body = (await res.json()) as { error: string }; expect(body.error).toBe("Internal Server Error"); }); }); // ─── GRO-2013 owner-bypass suite ──────────────────────────────────────────── describe("GET /:id/profile-summary — owner-bypass (GRO-2013)", () => { it("returns 404 when the pet does not exist", async () => { enqueue("pets", []); const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(404); }); it("returns 200 with aggregated profile for a manager", async () => { enqueue("pets", petsTable); enqueue("appointments", appointmentsTable); enqueue("appointments", [{ count: 1 }]); enqueue("appointments", []); const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(200); const body = await res.json(); expect(body.id).toBe(PET_ID); expect(body.name).toBe("Biscuit"); expect(body.recentGroomingHistory).toBeInstanceOf(Array); expect(body.visitCount).toBe(1); expect(body.upcomingAppointment).toBeNull(); }); it("returns 200 for a groomer with appointment linkage to the pet's client", async () => { enqueue("pets", petsTable); enqueue("appointments", [{ id: "appt-1" }]); // linkage found enqueue("appointments", appointmentsTable); enqueue("appointments", [{ count: 1 }]); enqueue("appointments", []); const app = buildApp(GROOMER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(200); }); it("returns 403 for a groomer with no appointment linkage and no bypass header", async () => { enqueue("pets", petsTable); enqueue("appointments", []); // no linkage const app = buildApp(GROOMER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(403); }); it("customer-as-groomer with valid active session for pet's client returns 200", async () => { enqueue("pets", petsTable); enqueue("impersonationSessions", sessionsTable); // active session found enqueue("appointments", appointmentsTable); enqueue("appointments", [{ count: 1 }]); enqueue("appointments", []); const app = buildApp(CUSTOMER_STAFF); const res = await app.request(`/pets/${PET_ID}/profile-summary`, { headers: { "X-Impersonation-Session-Id": "sess-owner" }, }); expect(res.status).toBe(200); const body = await res.json(); expect(body.id).toBe(PET_ID); }); it("customer-as-groomer with no header still gets 403 (no bypass)", async () => { enqueue("pets", petsTable); const app = buildApp(CUSTOMER_STAFF); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(403); }); it("customer-as-groomer with session for a DIFFERENT client gets 403 (cross-tenant blocked)", async () => { // Session exists but clientId !== pet.clientId → bypass does not apply // → falls through to groomer linkage check → no linkage → 403 enqueue("pets", petsTable); enqueue("impersonationSessions", [ makeSession({ id: "sess-other-client", clientId: "c0000000-0000-0000-0000-000000000099", // different from CLIENT_ID }), ]); enqueue("appointments", []); // no linkage → 403 const app = buildApp(CUSTOMER_STAFF); const res = await app.request(`/pets/${PET_ID}/profile-summary`, { headers: { "X-Impersonation-Session-Id": "sess-other-client" }, }); expect(res.status).toBe(403); }); it("customer-as-groomer with expired session still gets 403", async () => { enqueue("pets", petsTable); enqueue("impersonationSessions", [ makeSession({ id: "sess-expired", expiresAt: pastDate() }), ]); enqueue("appointments", []); // no linkage → 403 const app = buildApp(CUSTOMER_STAFF); const res = await app.request(`/pets/${PET_ID}/profile-summary`, { headers: { "X-Impersonation-Session-Id": "sess-expired" }, }); expect(res.status).toBe(403); }); it("customer-as-groomer with ended (status != active) session still gets 403", async () => { enqueue("pets", petsTable); enqueue("impersonationSessions", [ makeSession({ id: "sess-ended", status: "ended" }), ]); enqueue("appointments", []); // no linkage → 403 const app = buildApp(CUSTOMER_STAFF); const res = await app.request(`/pets/${PET_ID}/profile-summary`, { headers: { "X-Impersonation-Session-Id": "sess-ended" }, }); expect(res.status).toBe(403); }); it("customer-as-groomer with unknown session id still gets 403", async () => { enqueue("pets", petsTable); enqueue("impersonationSessions", []); // session not found enqueue("appointments", []); // no linkage → 403 const app = buildApp(CUSTOMER_STAFF); const res = await app.request(`/pets/${PET_ID}/profile-summary`, { headers: { "X-Impersonation-Session-Id": "sess-unknown" }, }); expect(res.status).toBe(403); }); it("manager does NOT need the impersonation header (existing role check still works)", async () => { enqueue("pets", petsTable); enqueue("appointments", appointmentsTable); enqueue("appointments", [{ count: 1 }]); enqueue("appointments", []); const app = buildApp(MANAGER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(200); }); it("groomer with linkage to pet's client still works (regression — no regression from bypass)", async () => { enqueue("pets", petsTable); enqueue("appointments", [{ id: "appt-1" }]); // linkage found enqueue("appointments", appointmentsTable); enqueue("appointments", [{ count: 1 }]); enqueue("appointments", []); const app = buildApp(GROOMER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(200); }); it("owner-bypass: customer cannot view another client's pet (cross-tenant block)", async () => { // The customer has a valid session for CLIENT_ID, but the pet belongs // to a different client → isOwner=false → falls through to groomer // linkage check → 403. enqueue("pets", [ makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }), ]); enqueue("impersonationSessions", sessionsTable); // valid session, but for CLIENT_ID enqueue("appointments", []); // no linkage → 403 const app = buildApp(CUSTOMER_STAFF); const res = await app.request(`/pets/${OTHER_CLIENT_PET_ID}/profile-summary`, { headers: { "X-Impersonation-Session-Id": "sess-owner" }, }); expect(res.status).toBe(403); }); }); // ─── GRO-2063 owner-bypass audit write ────────────────────────────────────── describe("GET /:id/profile-summary — owner-bypass audit row (GRO-2063)", () => { it("writes exactly one audit row on the owner-bypass success path", async () => { enqueue("pets", petsTable); enqueue("impersonationSessions", sessionsTable); // valid active session for CLIENT_ID enqueue("appointments", appointmentsTable); enqueue("appointments", [{ count: 1 }]); enqueue("appointments", []); const app = buildApp(CUSTOMER_STAFF); const res = await app.request(`/pets/${PET_ID}/profile-summary`, { headers: { "X-Impersonation-Session-Id": "sess-owner" }, }); expect(res.status).toBe(200); const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs"); expect(auditInserts).toHaveLength(1); const vals = auditInserts[0]!.vals; expect(vals.action).toBe("read_profile_summary"); expect(vals.sessionId).toBe("sess-owner"); expect(vals.pageVisited).toBe(`/pets/${PET_ID}/profile-summary`); expect(vals.metadata).toEqual({ petId: PET_ID, actorStaffId: CUSTOMER_STAFF.id, }); }); it("does NOT write an audit row on the normal groomer-linkage success path", async () => { // GROOMER is a "real" groomer with appointment linkage, NOT the // auto-provisioned customer-as-groomer. No impersonation header is // present, so the owner-bypass branch never executes. enqueue("pets", petsTable); enqueue("appointments", [{ id: "appt-1" }]); // linkage found enqueue("appointments", appointmentsTable); enqueue("appointments", [{ count: 1 }]); enqueue("appointments", []); const app = buildApp(GROOMER); const res = await app.request(`/pets/${PET_ID}/profile-summary`); expect(res.status).toBe(200); const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs"); expect(auditInserts).toHaveLength(0); }); it("does NOT write an audit row when the owner-bypass attempt is denied (cross-tenant)", async () => { // Customer has a valid session but it points at a different client. // isOwner=false, falls through to groomer linkage check, returns 403. enqueue("pets", [ makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }), ]); enqueue("impersonationSessions", sessionsTable); // session is for CLIENT_ID enqueue("appointments", []); // no linkage → 403 const app = buildApp(CUSTOMER_STAFF); const res = await app.request(`/pets/${OTHER_CLIENT_PET_ID}/profile-summary`, { headers: { "X-Impersonation-Session-Id": "sess-owner" }, }); expect(res.status).toBe(403); const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs"); expect(auditInserts).toHaveLength(0); }); });