fix(api): GRO-2014 — profile-summary returns 404/401/JSON-500 instead of empty-body 500
Defect: GET /api/pets/:id/profile-summary previously returned an empty-body
500 Internal Server Error for any UUID that the caller had no linkage to
(and presumably also for any malformed/non-UUID path param), because the
route had no upfront UUID validation, no defensive staff context guard,
and no router-level onError to catch downstream Drizzle/Postgres errors.
Changes:
- src/routes/pets.ts
- Add router.onError that returns a JSON envelope (`{"error":"Internal Server
Error"}`) instead of Hono's default empty-body 500. Mirrors the pattern
already used in invoices.ts and reports.ts.
- profile-summary: validate the :id path param with z.string().uuid()
before hitting Postgres. Malformed UUIDs now return 404 Not Found
instead of triggering a Postgres uuid cast that throws and bubbles
up as a 500.
- profile-summary: explicit `if (!staffRow)` guard returns 401 instead
of relying on optional chaining and risking a TypeError later in the
groomer linkage check on staffRow.id.
- src/__tests__/petProfileSummary.test.ts (new)
- 7 regression tests covering: malformed UUID → 404; missing staff →
401; pet not found → 404; groomer with no linkage → 403; manager
happy path → 200; groomer with linkage → 200; downstream DB throw
→ 500 with JSON body (never empty body).
- UAT_PLAYBOOK.md §3 (TC-API-3.29 / 3.30 / 3.31)
- Document the new 404 behaviour for unknown and malformed UUIDs and
the JSON-envelope requirement for any 500.
Notes for QA:
- Spec from GRO-2014: 404 if pet does not exist, 403 if no linkage, 401
if not authenticated. The "Forbidden if no linkage" path was already
correct for groomers; the 500 → 404/JSON-500 collapse is the actual
change in observable behaviour.
- The route's customer-as-groomer auto-provision issue (GRO-2013) is
*not* addressed here. It remains the customer-side defect; this PR
only fixes the error-handling regression.
Refs: GRO-1892, GRO-2013
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -125,6 +125,9 @@ CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
|
||||
| TC-API-3.17 | Get pet profile summary — groomer restricted | GET /api/pets/{id}/profile-summary as groomer with no pet linkage | 403 Forbidden |
|
||||
| TC-API-3.18 | Get pet profile summary — visitCount returns full count | GET /api/pets/{id}/profile-summary with 2+ completed appointments | visitCount >= 2 (not capped at 1) |
|
||||
| TC-API-3.19 | Get pet profile summary — upcomingAppointment excludes past | GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment | upcomingAppointment is null (past appointments filtered by startTime >= now) |
|
||||
| TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) |
|
||||
| TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) |
|
||||
| TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` |
|
||||
|
||||
#### Seed Data Verification (GRO-1898)
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* GET /pets/:id/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.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MANAGER: StaffRow = {
|
||||
id: "00000000-0000-0000-0000-0000000000aa",
|
||||
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: "00000000-0000-0000-0000-0000000000bb",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
role: "groomer",
|
||||
isSuperUser: false,
|
||||
name: "Groomer Gary",
|
||||
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"),
|
||||
};
|
||||
|
||||
// ─── Mutable DB state ─────────────────────────────────────────────────────────
|
||||
|
||||
interface DbState {
|
||||
petRow: typeof PET_ROW | null;
|
||||
linkageRow: { id: string } | null;
|
||||
recentHistory: Array<Record<string, unknown>>;
|
||||
visitCount: number;
|
||||
upcoming: Record<string, unknown> | null;
|
||||
throwOnPetSelect: boolean;
|
||||
}
|
||||
|
||||
let dbState: DbState;
|
||||
|
||||
function resetDb() {
|
||||
dbState = {
|
||||
petRow: { ...PET_ROW },
|
||||
linkageRow: { id: "appt-link" },
|
||||
recentHistory: [],
|
||||
visitCount: 0,
|
||||
upcoming: null,
|
||||
throwOnPetSelect: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── @groombook/db mock ──────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
// Each "select chain" needs to know which table it's targeting so we can
|
||||
// hand back the right mocked rows. We can't tell tables apart by reference
|
||||
// in Drizzle-land, so use named proxies and inspect them in `from()`.
|
||||
const named = (name: string) =>
|
||||
new Proxy(
|
||||
{ _name: name },
|
||||
{
|
||||
get(_t, p) {
|
||||
if (p === "_name") return name;
|
||||
return { table: name, column: p };
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const pets = named("pets");
|
||||
const appointments = named("appointments");
|
||||
const services = named("services");
|
||||
const staff = named("staff");
|
||||
|
||||
function buildSelect(projection?: Record<string, unknown>) {
|
||||
let targetTable = "";
|
||||
const chain: Record<string, (...args: unknown[]) => unknown> = {};
|
||||
|
||||
chain.from = (table: { _name: string }) => {
|
||||
targetTable = table._name;
|
||||
return chain;
|
||||
};
|
||||
chain.innerJoin = () => chain;
|
||||
chain.leftJoin = () => chain;
|
||||
chain.orderBy = () => chain;
|
||||
chain.limit = () => chain;
|
||||
|
||||
// .where(...) on the pets-select is the terminal call in the route — it
|
||||
// is awaited directly. Other queries chain through .limit/.orderBy. We
|
||||
// make every chain "thenable" so any await position resolves to rows.
|
||||
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") {
|
||||
// Disambiguate by projection shape:
|
||||
// - linkage check projects `{ id: appointments.id }`
|
||||
// - recentHistory projects multiple columns including serviceName
|
||||
// - visit count projects `{ count: ... }`
|
||||
// - upcoming projects multiple columns including confirmationStatus
|
||||
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 [];
|
||||
};
|
||||
chain.where = (..._args: unknown[]) => {
|
||||
// After .where, the chain is still awaitable. Return chain itself so
|
||||
// .limit/.orderBy can follow, but also expose `then` for the case
|
||||
// where .where is the last call (pets-select).
|
||||
return chain;
|
||||
};
|
||||
// Make the whole chain thenable so any await position works.
|
||||
(chain as unknown as PromiseLike<unknown[]>).then = (
|
||||
onFulfilled?: (v: unknown[]) => unknown
|
||||
) => Promise.resolve(resolveRows()).then(onFulfilled);
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: (projection?: Record<string, unknown>) => buildSelect(projection),
|
||||
}),
|
||||
pets,
|
||||
appointments,
|
||||
services,
|
||||
staff,
|
||||
and: vi.fn((..._args: unknown[]) => ({ _op: "and" })),
|
||||
or: vi.fn((..._args: unknown[]) => ({ _op: "or" })),
|
||||
eq: vi.fn((..._args: unknown[]) => ({ _op: "eq" })),
|
||||
desc: vi.fn((arg: unknown) => arg),
|
||||
exists: vi.fn((arg: unknown) => arg),
|
||||
sql: Object.assign(
|
||||
(..._args: unknown[]) => ({ _op: "sql" }),
|
||||
// tag template fallback
|
||||
{ [Symbol.toPrimitive]: () => "sql" }
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
const { petsRouter } = await import("../routes/pets.js");
|
||||
|
||||
// ─── App builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildApp(staffRow: StaffRow | null) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
if (staffRow) c.set("staff", staffRow);
|
||||
await next();
|
||||
});
|
||||
app.route("/pets", petsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetDb();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("GET /pets/: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 () => {
|
||||
dbState.petRow = null;
|
||||
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 () => {
|
||||
dbState.linkageRow = null;
|
||||
const app = buildApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_UUID}/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 () => {
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_UUID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.id).toBe(PET_UUID);
|
||||
expect(body.name).toBe("Biscuit");
|
||||
expect(body.visitCount).toBe(0);
|
||||
expect(body.upcomingAppointment).toBeNull();
|
||||
expect(body.recentGroomingHistory).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns 200 with summary for a groomer with linkage", async () => {
|
||||
const app = buildApp(GROOMER);
|
||||
const res = await app.request(`/pets/${PET_UUID}/profile-summary`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.id).toBe(PET_UUID);
|
||||
});
|
||||
|
||||
it("returns a JSON envelope (not empty body) when a downstream query throws", async () => {
|
||||
dbState.throwOnPetSelect = true;
|
||||
const app = buildApp(MANAGER);
|
||||
const res = await app.request(`/pets/${PET_UUID}/profile-summary`);
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("Internal Server Error");
|
||||
});
|
||||
});
|
||||
+34
-1
@@ -23,6 +23,23 @@ import {
|
||||
|
||||
export const petsRouter = new Hono<AppEnv>();
|
||||
|
||||
// Convert Zod validation errors from 422 to 400 and ensure any thrown error
|
||||
// returns a structured JSON body rather than Hono's default empty-body 500.
|
||||
// GRO-2014: profile-summary previously bubbled unhandled errors and produced
|
||||
// an empty-body 500. Mirror the onError pattern already used in invoices.ts
|
||||
// and reports.ts so every error has a JSON envelope.
|
||||
petsRouter.onError((err, c) => {
|
||||
if (err instanceof z.ZodError) {
|
||||
return c.json({ error: "Validation failed", issues: err.issues }, 400);
|
||||
}
|
||||
console.error("[pets] unhandled error", err);
|
||||
return c.json({ error: "Internal Server Error" }, 500);
|
||||
});
|
||||
|
||||
// UUID format used by all pet routes — guards path params against malformed
|
||||
// values before they hit Drizzle / Postgres uuid columns (which would throw).
|
||||
const uuidSchema = z.string().uuid();
|
||||
|
||||
const createPetSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -112,8 +129,24 @@ petsRouter.get("/:id", async (c) => {
|
||||
petsRouter.get("/:id/profile-summary", async (c) => {
|
||||
const db = getDb();
|
||||
const petId = c.req.param("id");
|
||||
|
||||
// GRO-2014: validate UUID format before hitting Postgres. Passing a non-UUID
|
||||
// string to a uuid column makes the driver throw, which previously surfaced
|
||||
// as an empty-body 500 to clients.
|
||||
const parsedId = uuidSchema.safeParse(petId);
|
||||
if (!parsedId.success) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
// Defense in depth: resolveStaffMiddleware should always populate `staff`
|
||||
// for protected routes (or short-circuit with 401/403 of its own). Guard
|
||||
// anyway so a misconfigured route mount can't trigger a TypeError on
|
||||
// staffRow.id when the linkage check runs.
|
||||
const staffRow = c.get("staff");
|
||||
const isGroomer = staffRow?.role === "groomer";
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
const isGroomer = staffRow.role === "groomer";
|
||||
|
||||
// Fetch the pet
|
||||
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||
|
||||
Reference in New Issue
Block a user