import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; const OTHER_CLIENT_ID = "550e8400-e29b-41d4-a716-446655440099"; const PET_ID = "880e8400-e29b-41d4-a716-446655440004"; const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); const ACTIVE_SESSION = { id: SESSION_ID, clientId: CLIENT_ID, status: "active" as const, expiresAt: futureDate(), createdAt: new Date(), }; // A persisted pet owned by CLIENT_ID. weightKg is a string because the column is // numeric (Drizzle serialises numeric to string). const PET = { id: PET_ID, clientId: CLIENT_ID, name: "Rex", species: "dog", breed: "Labrador", weightKg: "12.50", dateOfBirth: null, healthAlerts: null, groomingNotes: null, coatType: null, petSizeCategory: null, preferredCuts: [], medicalAlerts: [], photoKey: null, }; let selectSessionRow: Record | null = null; let selectPetRow: Record | null = null; let updatedValues: Record[] = []; function resetMock() { selectSessionRow = null; selectPetRow = null; updatedValues = []; } vi.mock("@groombook/db", () => { function makeChainable(data: unknown[]): unknown { const arr = [...data]; const chain = new Proxy(arr, { get(target, prop) { if (prop === "where" || prop === "orderBy" || prop === "limit") { return () => chain; } // @ts-expect-error proxy return target[prop]; }, }); return chain; } function tableProxy(name: string) { return new Proxy( { _name: name }, { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } ); } const impersonationSessions = tableProxy("impersonationSessions"); const pets = tableProxy("pets"); return { getDb: () => ({ select: () => ({ from: (table: { _name: string }) => { if (table._name === "impersonationSessions") { return makeChainable(selectSessionRow ? [selectSessionRow] : []); } if (table._name === "pets") { return makeChainable(selectPetRow ? [selectPetRow] : []); } return makeChainable([]); }, }), update: () => ({ set: (vals: Record) => ({ where: () => ({ returning: () => { if (selectPetRow) { updatedValues.push(vals); return [{ ...selectPetRow, ...vals }]; } return []; }, }), }), }), // portalAudit inserts an audit row after the handler; make it a no-op so // the middleware does not log a swallowed error during tests. insert: () => ({ values: () => ({ returning: () => [] }) }), }), impersonationSessions, pets, // Other tables imported by the portal router but unused in these tests. appointments: tableProxy("appointments"), waitlistEntries: tableProxy("waitlistEntries"), clients: tableProxy("clients"), services: tableProxy("services"), staff: tableProxy("staff"), invoices: tableProxy("invoices"), invoiceLineItems: tableProxy("invoiceLineItems"), impersonationAuditLogs: tableProxy("impersonationAuditLogs"), eq: vi.fn(), and: vi.fn(), inArray: vi.fn(), }; }); const { portalRouter } = await import("../routes/portal.js"); const app = new Hono(); app.route("/portal", portalRouter); function jsonPatch(path: string, body: unknown, headers?: Record) { return app.request(path, { method: "PATCH", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify(body), }); } beforeEach(() => resetMock()); describe("PATCH /portal/pets/:petId", () => { it("updates an owned pet and persists the mapped columns (200)", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = PET; // Mirrors the groombook/web PetForm payload: it spreads the GET-shaped pet // (weight, notes, birthDate, photoUrl) and adds the form's edited keys // (weightKg, healthAlerts, coatType, …). "xlarge" must map to "extra_large". const res = await jsonPatch( `/portal/pets/${PET_ID}`, { id: PET_ID, name: "Rex Updated", breed: "Golden Retriever", weight: "12.50", weightKg: 18.25, notes: "old grooming notes", healthAlerts: "Allergic to oatmeal shampoo", photoUrl: "pets/rex.jpg", coatType: "double", petSizeCategory: "xlarge", preferredCuts: ["teddy bear", "puppy cut"], medicalAlerts: [ { id: "a1", type: "allergy", description: "oatmeal", severity: "medium" }, ], }, { "X-Impersonation-Session-Id": SESSION_ID } ); expect(res.status).toBe(200); const body = await res.json(); expect(body.name).toBe("Rex Updated"); expect(body.petSizeCategory).toBe("extra_large"); expect(body.coatType).toBe("double"); const persisted = updatedValues[0]!; expect(persisted.name).toBe("Rex Updated"); expect(persisted.breed).toBe("Golden Retriever"); // weightKg (form key) wins over weight (GET key) and is stored as a string. expect(persisted.weightKg).toBe("18.25"); expect(persisted.groomingNotes).toBe("old grooming notes"); expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo"); // photoKey is NOT writable via portal PATCH (GRO-2187 S3 key-hijack fix): // the web form round-trips the GET-shaped photoUrl, but the server must not // persist it. Photo changes go through the key-validated upload flow. expect(persisted.photoKey).toBeUndefined(); expect(persisted.coatType).toBe("double"); expect(persisted.petSizeCategory).toBe("extra_large"); expect(persisted.preferredCuts).toEqual(["teddy bear", "puppy cut"]); expect(persisted.medicalAlerts).toEqual([ { id: "a1", type: "allergy", description: "oatmeal", severity: "medium" }, ]); expect(persisted.updatedAt).toBeInstanceOf(Date); }); // GRO-2187 security regression: a portal customer must not be able to set the // S3 object key. photoKey is consumed server-side by getPresignedGetUrl / // deleteObject; the upload path guards keys with a pets/{petId}/ prefix, and the // portal PATCH must not offer a bypass. A foreign/arbitrary photoUrl is accepted // (Zod strips the unknown key) but must leave photoKey untouched. it("does not mutate photoKey when a foreign photoUrl is supplied (200)", async () => { selectSessionRow = ACTIVE_SESSION; const ownKey = `pets/${PET_ID}/original.jpg`; selectPetRow = { ...PET, photoKey: ownKey }; const res = await jsonPatch( `/portal/pets/${PET_ID}`, { name: "Rex", // attacker-chosen key pointing at another tenant's object photoUrl: "pets/00000000-0000-0000-0000-0000000000ff/victim-secret.jpg", }, { "X-Impersonation-Session-Id": SESSION_ID } ); expect(res.status).toBe(200); const persisted = updatedValues[0]!; // The attacker-supplied key never reaches the update payload. expect(persisted.photoKey).toBeUndefined(); // And the stored key is unchanged from the pet's own value. const body = await res.json(); expect(body.photoUrl).toBe(ownKey); }); // The length/array caps live in the Zod schema, so violations are rejected by // zValidator with 400 (in-handler enum checks are what return 422). it("returns 400 when a medicalAlert description exceeds the length cap", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = PET; const res = await jsonPatch( `/portal/pets/${PET_ID}`, { medicalAlerts: [ { type: "allergy", description: "x".repeat(2001), severity: "low" }, ], }, { "X-Impersonation-Session-Id": SESSION_ID } ); expect(res.status).toBe(400); expect(updatedValues).toHaveLength(0); }); it("falls back to the weight key when weightKg is absent", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = PET; const res = await jsonPatch( `/portal/pets/${PET_ID}`, { weight: "9.75" }, { "X-Impersonation-Session-Id": SESSION_ID } ); expect(res.status).toBe(200); expect(updatedValues[0]!.weightKg).toBe("9.75"); }); it("returns 403 when the pet belongs to a different client", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = { ...PET, clientId: OTHER_CLIENT_ID }; const res = await jsonPatch( `/portal/pets/${PET_ID}`, { name: "Hacker" }, { "X-Impersonation-Session-Id": SESSION_ID } ); expect(res.status).toBe(403); expect(updatedValues).toHaveLength(0); }); it("returns 404 when the pet does not exist", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = null; const res = await jsonPatch( `/portal/pets/${PET_ID}`, { name: "Ghost" }, { "X-Impersonation-Session-Id": SESSION_ID } ); expect(res.status).toBe(404); }); it("returns 404 for a malformed (non-UUID) petId without hitting the db (GRO-2203)", async () => { selectSessionRow = ACTIVE_SESSION; // A non-UUID petId previously reached `where(eq(pets.id, ...))` and made // Postgres throw "invalid input syntax for type uuid" → unhandled 500. // It must now short-circuit to 404 before any select/update. selectPetRow = PET; const res = await jsonPatch( `/portal/pets/not-a-uuid`, { coatType: "short" }, { "X-Impersonation-Session-Id": SESSION_ID } ); expect(res.status).toBe(404); expect(updatedValues).toHaveLength(0); }); it("returns 422 for an invalid coatType", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = PET; const res = await jsonPatch( `/portal/pets/${PET_ID}`, { coatType: "fluffy" }, { "X-Impersonation-Session-Id": SESSION_ID } ); expect(res.status).toBe(422); expect(updatedValues).toHaveLength(0); }); it("returns 422 for an invalid petSizeCategory", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = PET; const res = await jsonPatch( `/portal/pets/${PET_ID}`, { petSizeCategory: "gigantic" }, { "X-Impersonation-Session-Id": SESSION_ID } ); expect(res.status).toBe(422); expect(updatedValues).toHaveLength(0); }); it("returns 401 without an impersonation session header", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = PET; const res = await jsonPatch(`/portal/pets/${PET_ID}`, { name: "NoAuth" }); expect(res.status).toBe(401); }); });