From a2b09ba502e268c3081bbe434ad69b0573e87865 Mon Sep 17 00:00:00 2001 From: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Date: Mon, 1 Jun 2026 20:06:24 +0000 Subject: [PATCH] fix(pets): port owner-bypass into deployed tree (GRO-2013) (#139) --- src/__tests__/petProfileSummary.test.ts | 594 +++++++++++++++++------- src/routes/pets.ts | 42 +- 2 files changed, 480 insertions(+), 156 deletions(-) diff --git a/src/__tests__/petProfileSummary.test.ts b/src/__tests__/petProfileSummary.test.ts index 8a17d43..069d1cb 100644 --- a/src/__tests__/petProfileSummary.test.ts +++ b/src/__tests__/petProfileSummary.test.ts @@ -1,23 +1,37 @@ /** - * GET /pets/:id/profile-summary tests + * Pet Profile Summary Tests * - * GRO-2014 regression coverage: - * - 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. + * 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"; -// ─── Fixtures ──────────────────────────────────────────────────────────────── +// ─── Staff fixtures ────────────────────────────────────────────────────────── const MANAGER: StaffRow = { - id: "00000000-0000-0000-0000-0000000000aa", + id: "staff-manager-id", oidcSub: "oidc-manager-sub", userId: null, role: "manager", @@ -32,7 +46,7 @@ const MANAGER: StaffRow = { const GROOMER: StaffRow = { ...MANAGER, - id: "00000000-0000-0000-0000-0000000000bb", + id: "staff-groomer-id", oidcSub: "oidc-groomer-sub", role: "groomer", isSuperUser: false, @@ -40,186 +54,274 @@ const GROOMER: StaffRow = { email: "groomer@example.com", }; -const PET_UUID = "11111111-1111-1111-1111-111111111111"; -const CLIENT_UUID = "22222222-2222-2222-2222-222222222222"; -const UNKNOWN_PET_UUID = "00000000-0000-0000-0000-000000000001"; - -const PET_ROW = { - id: PET_UUID, - clientId: CLIENT_UUID, - name: "Biscuit", - species: "dog", - breed: "Beagle", - coatType: "short", - petSizeCategory: "medium", - weightKg: "12.50", - dateOfBirth: new Date("2020-01-01"), +/** + * 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 DB state ───────────────────────────────────────────────────────── +// ─── Mutable mock state ───────────────────────────────────────────────────── -interface DbState { - petRow: typeof PET_ROW | null; - linkageRow: { id: string } | null; - recentHistory: Array>; - visitCount: number; - upcoming: Record | null; - throwOnPetSelect: boolean; -} +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"; -let dbState: DbState; +const futureDate = () => new Date(Date.now() + 30 * 60_000); +const pastDate = () => new Date(Date.now() - 5 * 60_000); -function resetDb() { - dbState = { - petRow: { ...PET_ROW }, - linkageRow: { id: "appt-link" }, - recentHistory: [], - visitCount: 0, - upcoming: null, - throwOnPetSelect: false, +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, }; } -// ─── @groombook/db mock ────────────────────────────────────────────────────── +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 makeService(overrides: Record = {}) { + return { + id: "service-1", + name: "Full Groom", + description: null, + basePriceCents: 6000, + durationMinutes: 120, + active: true, + createdAt: new Date(), + updatedAt: new Date(), + ...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 servicesTable: 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()). // -// Each select chain needs to know which table it's targeting and which columns -// it's projecting so we can return the right mocked rows. We thread that state -// through a per-call object whose chain methods all return `this`. The chain -// is also `then`-able so any `await` position resolves to the rows. +// 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; +}> = []; + +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()]; + servicesTable = [makeService()]; + sessionsTable = [makeSession()]; + selectQueue = []; +} + +// ─── Module mocks ─────────────────────────────────────────────────────────── vi.mock("@groombook/db", () => { - const namedTable = (name: string) => - new Proxy( + function makeTable(name: string) { + return new Proxy( { _name: name }, { - get(_t, p) { - if (p === "_name") return name; - return { table: name, column: p }; + get(target, prop) { + if (prop === "_name") return name; + if (prop === "$inferSelect") return {}; + return { table: name, column: prop }; }, } ); - - const pets = namedTable("pets"); - const appointments = namedTable("appointments"); - const services = namedTable("services"); - const staff = namedTable("staff"); - - // The full chain interface is intentionally loose — only `then` is exposed - // with a typed signature so vitest's await resolves to the right shape. - interface ChainLike { - from: (table: { _name: string }) => ChainLike; - where: (...args: unknown[]) => ChainLike; - innerJoin: (...args: unknown[]) => ChainLike; - leftJoin: (...args: unknown[]) => ChainLike; - orderBy: (...args: unknown[]) => ChainLike; - limit: (...args: unknown[]) => ChainLike; - then: ( - onfulfilled?: ((value: unknown[]) => T | PromiseLike) | null - ) => Promise; } - function buildSelect(projection?: Record): ChainLike { - let targetTable = ""; - - const resolveRows = (): unknown[] => { - if (targetTable === "pets") { - if (dbState.throwOnPetSelect) { - throw new Error("simulated postgres uuid cast failure"); - } - return dbState.petRow ? [dbState.petRow] : []; - } - if (targetTable === "appointments") { - const keys = projection ? Object.keys(projection) : []; - if (projection && keys.length === 1 && keys[0] === "id") { - return dbState.linkageRow ? [dbState.linkageRow] : []; - } - if (projection && keys.includes("count")) { - return [{ count: dbState.visitCount }]; - } - if (projection && keys.includes("confirmationStatus")) { - return dbState.upcoming ? [dbState.upcoming] : []; - } - return dbState.recentHistory; - } - return []; + function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) { + const queryString = _strings[0]; + return { + queryChunks: [queryString], + as: (alias: string) => ({ + queryChunks: [queryString], + fieldAlias: alias, + getSQL() { return this.queryChunks; }, + }), }; + } - const chain: ChainLike = { - from(table) { - targetTable = table._name; - return chain; - }, - where() { - return chain; - }, - innerJoin() { - return chain; - }, - leftJoin() { - return chain; - }, - orderBy() { - return chain; - }, - limit() { - return chain; - }, - then(onfulfilled) { - return Promise.resolve(resolveRows()).then(onfulfilled ?? undefined); - }, - }; + 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 []; + } - return chain; + // 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: (projection?: Record) => buildSelect(projection), + select: (_cols?: Record) => ({ + from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")), + }), + insert: () => ({ values: () => ({ returning: () => [{}] }) }), + update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }), + delete: () => ({ where: () => ({ returning: () => [{}] }) }), }), - pets, - appointments, - services, - staff, - and: vi.fn(() => ({ _op: "and" })), - or: vi.fn(() => ({ _op: "or" })), - eq: vi.fn(() => ({ _op: "eq" })), - desc: vi.fn((arg: unknown) => arg), - exists: vi.fn((arg: unknown) => arg), - sql: Object.assign( - () => ({ _op: "sql" }), - { [Symbol.toPrimitive]: () => "sql" } - ), + pets: makeTable("pets"), + appointments: makeTable("appointments"), + staff: makeTable("staff"), + services: makeTable("services"), + impersonationSessions: makeTable("impersonationSessions"), + 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().mockResolvedValue("https://example.com/put"), - getPresignedGetUrl: vi.fn().mockResolvedValue("https://example.com/get"), - deleteObject: vi.fn().mockResolvedValue(undefined), + getPresignedUploadUrl: vi.fn(), + getPresignedGetUrl: vi.fn(), + deleteObject: vi.fn(), })); +// ─── Import after mocks are set up ────────────────────────────────────────── + const { petsRouter } = await import("../routes/pets.js"); -// ─── App builder ───────────────────────────────────────────────────────────── +// ─── App builder ──────────────────────────────────────────────────────────── function buildApp(staffRow: StaffRow | null) { const app = new Hono(); app.use("*", async (c, next) => { - if (staffRow) c.set("staff", staffRow); + 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(() => { - resetDb(); + resetMock(); vi.clearAllMocks(); }); -// ─── Tests ─────────────────────────────────────────────────────────────────── +// ─── GRO-2014 error-handling suite ────────────────────────────────────────── -describe("GET /pets/:id/profile-summary — GRO-2014 error handling", () => { +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"); @@ -237,7 +339,7 @@ describe("GET /pets/:id/profile-summary — GRO-2014 error handling", () => { }); it("returns 404 when authenticated and pet does not exist", async () => { - dbState.petRow = null; + enqueue("pets", []); const app = buildApp(MANAGER); const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`); expect(res.status).toBe(404); @@ -246,40 +348,222 @@ describe("GET /pets/:id/profile-summary — GRO-2014 error handling", () => { }); it("returns 403 when groomer has no appointment linkage to the pet's client", async () => { - dbState.linkageRow = null; + enqueue("pets", petsTable); + enqueue("appointments", []); // linkage check returns empty → 403 const app = buildApp(GROOMER); - const res = await app.request(`/pets/${PET_UUID}/profile-summary`); + 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_UUID}/profile-summary`); + 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_UUID); + expect(body.id).toBe(PET_ID); expect(body.name).toBe("Biscuit"); - expect(body.visitCount).toBe(0); + expect(body.visitCount).toBe(1); expect(body.upcomingAppointment).toBeNull(); - expect(body.recentGroomingHistory).toEqual([]); + expect(body.recentGroomingHistory).toBeInstanceOf(Array); }); - it("returns 200 with summary for a groomer with linkage", async () => { + 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_UUID}/profile-summary`); + 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_UUID); + expect(body.id).toBe(PET_ID); }); it("returns a JSON envelope (not empty body) when a downstream query throws", async () => { - dbState.throwOnPetSelect = true; + enqueueThrow("pets", "simulated postgres uuid cast failure"); const app = buildApp(MANAGER); - const res = await app.request(`/pets/${PET_UUID}/profile-summary`); + 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); + }); +}); diff --git a/src/routes/pets.ts b/src/routes/pets.ts index 9695af7..45dcde2 100644 --- a/src/routes/pets.ts +++ b/src/routes/pets.ts @@ -7,6 +7,7 @@ import { eq, exists, getDb, + impersonationSessions, or, pets, appointments, @@ -126,6 +127,35 @@ petsRouter.get("/:id", async (c) => { return c.json(row); }); +/** + * Resolves the clientId from the X-Impersonation-Session-Id header, if present and active. + * Used by staff routes to allow a customer (auto-provisioned as a `groomer` staff row + * by rbac.ts) to access their own pet's data when they are the rightful owner. + * + * Returns null when the header is missing, the session is unknown/expired/ended, or the + * session exists but has no clientId — callers should treat null as "no owner-bypass". + */ +async function resolveImpersonationClientId( + db: ReturnType, + c: { req: { header: (name: string) => string | undefined } } +): Promise { + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) return null; + const [session] = await db + .select({ + clientId: impersonationSessions.clientId, + status: impersonationSessions.status, + expiresAt: impersonationSessions.expiresAt, + }) + .from(impersonationSessions) + .where(eq(impersonationSessions.id, sessionId)) + .limit(1); + if (!session) return null; + if (session.status !== "active") return null; + if (session.expiresAt <= new Date()) return null; + return session.clientId; +} + petsRouter.get("/:id/profile-summary", async (c) => { const db = getDb(); const petId = c.req.param("id"); @@ -152,8 +182,18 @@ petsRouter.get("/:id/profile-summary", async (c) => { const [pet] = await db.select().from(pets).where(eq(pets.id, petId)); if (!pet) return c.json({ error: "Not found" }, 404); - // Groomer RBAC: check appointment linkage to this pet's client + // Owner-bypass (GRO-2013): a customer who supplies a valid + // X-Impersonation-Session-Id for the pet's owning client may read their + // own pet's summary, even though rbac.ts auto-provisions them as a + // `groomer` staff row with no appointment linkage. + let isOwner = false; if (isGroomer) { + const ownerClientId = await resolveImpersonationClientId(db, c); + isOwner = !!ownerClientId && ownerClientId === pet.clientId; + } + + // Groomer RBAC: check appointment linkage to this pet's client + if (isGroomer && !isOwner) { const [linkage] = await db .select({ id: appointments.id }) .from(appointments)