Compare commits

..

1 Commits

Author SHA1 Message Date
Paperclip 337c0e2733 docs(UAT_PLAYBOOK): document canonical source-of-truth for UAT seed passwords (GRO-2000)
CI / Test (pull_request) Successful in 10s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Build & Push Docker Images (pull_request) Successful in 36s
The 'Source of truth for UAT passwords' subsection under Pre-conditions
records:

- The seed-uat-passwords Secret in groombook-uat is the live source.
- The Bitnami SealedSecret apps/overlays/uat/ss-seed-uat-passwords.yaml
  in groombook/infra is the single upstream source of truth.
- A kubectl recipe to pull the current values for SUPER / GROOMER /
  TESTER / CUSTOMER at the start of every UAT run.
- The 'captured env var from a previous rotation produces 401' failure
  mode that GRO-2000 hit, and the manual-reseed escape hatch if the
  login still 401s after pulling the live value.

Refs: GRO-2000, GRO-1977 (idempotent re-hash), GRO-1999 (enum fix that
allowed the seed Job to run cleanly again).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:30:34 +00:00
10 changed files with 3 additions and 586 deletions
-29
View File
@@ -156,32 +156,3 @@ jobs:
${{ github.ref == 'refs/heads/main' && 'git.farh.net/groombook/reset:latest' || '' }}
cache-from: type=registry,ref=git.farh.net/groombook/cache:reset
cache-to: type=registry,ref=git.farh.net/groombook/cache:reset,mode=max
- name: Smoke test seed image (blackhole npmjs.org)
run: |
set -euo pipefail
IMAGE="git.farh.net/groombook/seed:${{ steps.version.outputs.tag }}"
docker pull "$IMAGE"
# GRO-1985: pnpm must be a real binary, not a Corepack shim, and must
# not try to reach registry.npmjs.org on invocation.
docker run --rm \
--add-host registry.npmjs.org:127.0.0.1 \
--entrypoint="" \
"$IMAGE" \
sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; pnpm --version'
echo "seed image: pnpm resolves to /usr/local/bin/pnpm and runs offline ✓"
- name: Smoke test reset image (blackhole npmjs.org)
run: |
set -euo pipefail
IMAGE="git.farh.net/groombook/reset:${{ steps.version.outputs.tag }}"
docker pull "$IMAGE"
# GRO-1985: pnpm must be a real binary, not a Corepack shim, and must
# not try to reach registry.npmjs.org on invocation. Validates the
# hard requirement from the issue: reset runs offline.
docker run --rm \
--add-host registry.npmjs.org:127.0.0.1 \
--entrypoint="" \
"$IMAGE" \
sh -c 'set -e; test "$(which pnpm)" = "/usr/local/bin/pnpm"; echo "HOME=$HOME"; pnpm --version'
echo "reset image: pnpm resolves to /usr/local/bin/pnpm, HOME=/tmp, runs offline ✓"
+1 -13
View File
@@ -3,12 +3,8 @@ FROM node:22-alpine AS base
# invocations of `pnpm` work without DNS access to registry.npmjs.org.
# The corepack shim delegates to corepack, which re-validates against
# npmjs.org on first use — that fails in air-gapped UAT seed/migrate/reset
# Jobs. GRO-1983 / GRO-1889 / GRO-1909 / GRO-1981 / GRO-1985.
# Jobs. GRO-1983 / GRO-1889 / GRO-1909.
RUN npm install -g pnpm@9.15.4
# Belt-and-braces: disable Corepack's download fallback so that even if a
# Corepack shim is somehow invoked at runtime, it will not try to fetch
# pnpm from registry.npmjs.org. Belt for the real-binary trousers. GRO-1985.
ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0
WORKDIR /app
# Install deps
@@ -30,8 +26,6 @@ RUN pnpm --filter @groombook/types build && \
# Runtime
FROM node:22-alpine AS runner
RUN npm install -g pnpm@9.15.4
# Same defence-in-depth as base: no Corepack fallback. GRO-1985.
ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0
WORKDIR /app
ENV NODE_ENV=production
@@ -52,18 +46,12 @@ CMD ["node", "dist/index.js"]
# Migrate stage — runs drizzle-kit migrate against the database
FROM builder AS migrate
# pnpm needs a writable HOME for any config/state it writes. With
# readOnlyRootFilesystem: true and runAsUser: 1000, /home/node is read-only.
# The job pods mount a writable emptyDir at /tmp; point HOME there. GRO-1985.
ENV HOME=/tmp
CMD ["pnpm", "--filter", "@groombook/db", "migrate"]
# Seed stage — populates the database with test data
FROM builder AS seed
ENV HOME=/tmp
CMD ["pnpm", "--filter", "@groombook/db", "seed"]
# Reset stage — drops all tables, re-runs migrations, and re-seeds
FROM builder AS reset
ENV HOME=/tmp
CMD ["pnpm", "--filter", "@groombook/db", "reset"]
-6
View File
@@ -125,12 +125,6 @@ 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.19a | Get pet profile summary — customer owner-bypass (GRO-2013) | Sign in as `uat-customer@groombook.dev`; `POST /api/portal/session-from-auth`; then `GET /api/pets/{ownPetId}/profile-summary` with header `X-Impersonation-Session-Id: {sessionId}` for either of the customer's seeded pets (`c0000001-0000-0000-0000-000000000002` UAT Pup Alpha, `c0000001-0000-0000-0000-000000000003` UAT Pup Beta) | 200 OK, aggregated profile returned (owner-bypass: customer with valid portal session for pet's clientId is allowed even though rbac.ts auto-provisions them as a `groomer` staff row with no appointment linkage) |
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
| TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) |
| 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)
@@ -44,7 +44,6 @@ interface MockState {
groomingLogs: Record<string, unknown>[];
staffMembers: Record<string, unknown>[];
services: Record<string, unknown>[];
impersonationSessions: Record<string, unknown>[];
}
let mock: MockState;
@@ -169,19 +168,6 @@ function resetMock() {
{ id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() },
{ id: "service-2", name: "Bath & Brush", description: null, basePriceCents: 4000, durationMinutes: 60, active: true, createdAt: new Date(), updatedAt: new Date() },
],
impersonationSessions: [
{
id: "sess-owner",
staffId: "staff-groomer-id",
clientId: CLIENT_ID,
reason: "sso-bridge",
status: "active",
startedAt: new Date("2024-11-01"),
endedAt: null,
expiresAt: new Date("2099-01-01T00:00:00Z"),
createdAt: new Date("2024-11-01"),
},
],
};
}
@@ -191,7 +177,6 @@ vi.mock("../db/index.js", () => {
const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} });
const staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
const impersonationSessions = new Proxy({ _name: "impersonationSessions" }, { get: (t, p) => p === "_name" ? "impersonationSessions" : {} });
// Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call
let selectedColumns: Record<string, Record<string, unknown>> = {};
@@ -263,7 +248,6 @@ vi.mock("../db/index.js", () => {
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
if (name === "staff") return makeChainable(mock.staffMembers);
if (name === "services") return makeChainable(mock.services);
if (name === "impersonationSessions") return makeChainable(mock.impersonationSessions);
return makeChainable([]);
},
};
@@ -277,7 +261,6 @@ vi.mock("../db/index.js", () => {
groomingVisitLogs,
staff,
services,
impersonationSessions,
and: vi.fn((a: unknown, b: unknown) => [a, b]),
desc: vi.fn((c: unknown) => c),
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
@@ -416,102 +399,4 @@ describe("GET /:id/profile-summary — empty history", () => {
expect(body.recentGroomingHistory).toEqual([]);
expect(body.lastVisitDate).toBeNull();
});
});
describe("GET /:id/profile-summary — owner-bypass via X-Impersonation-Session-Id (GRO-2013)", () => {
beforeEach(resetMock);
// Simulates the rbac.ts auto-provisioned "groomer" that a customer gets on first login:
// role=groomer, no linkage to any appointment.
const CUSTOMER_STAFF: StaffRow = {
id: "staff-customer-id",
oidcSub: null,
userId: "user-customer-id",
role: "groomer",
isSuperUser: false,
name: "UAT Customer",
email: "uat-customer@groombook.dev",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
it("customer with valid portal session for pet's client returns 200 (owner-bypass)", async () => {
const app = makeApp(CUSTOMER_STAFF);
// Groomer has no appointment linkage — proves the bypass is via portal session, not linkage.
mock.appointments = [];
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);
expect(body.name).toBe("Biscuit");
expect(body.clientId).toBe(CLIENT_ID);
});
it("customer without X-Impersonation-Session-Id header still gets 403 (no bypass)", async () => {
const app = makeApp(CUSTOMER_STAFF);
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("customer with portal session for a DIFFERENT client gets 403 (cross-tenant blocked)", async () => {
const app = makeApp(CUSTOMER_STAFF);
mock.appointments = [];
mock.impersonationSessions = [
{
id: "sess-other-client",
staffId: "staff-customer-id",
clientId: "00000000-0000-0000-0000-000000000099", // different from CLIENT_ID
reason: "sso-bridge",
status: "active",
startedAt: new Date("2024-11-01"),
endedAt: null,
expiresAt: new Date("2099-01-01T00:00:00Z"),
createdAt: new Date("2024-11-01"),
},
];
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 with expired portal session still gets 403", async () => {
const app = makeApp(CUSTOMER_STAFF);
mock.appointments = [];
mock.impersonationSessions = [
{
id: "sess-expired",
staffId: "staff-customer-id",
clientId: CLIENT_ID,
reason: "sso-bridge",
status: "active",
startedAt: new Date("2024-01-01"),
endedAt: null,
expiresAt: new Date("2024-02-01T00:00:00Z"), // expired long ago
createdAt: new Date("2024-01-01"),
},
];
const res = await app.request(`/pets/${PET_ID}/profile-summary`, {
headers: { "X-Impersonation-Session-Id": "sess-expired" },
});
expect(res.status).toBe(403);
});
it("manager does NOT need the impersonation header (existing role check still works)", async () => {
const app = makeApp(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 () => {
const app = makeApp(GROOMER);
// GROOMER fixture has appointments linked to staff-groomer-id in the mock state
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
});
+1 -37
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, impersonationSessions, or, pets, appointments, staff, services, sql } from "../db/index.js";
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
@@ -307,38 +307,10 @@ async function groomerLinkageCheck(
return !!linkage;
}
/**
* 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<typeof getDb>,
c: { req: { header: (name: string) => string | undefined } }
): Promise<string | null> {
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;
}
/**
* GET /:id/profile-summary
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
* Groomer RBAC: same visibility rules as GET /:id.
* 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.
*/
petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb();
@@ -349,15 +321,7 @@ petsRouter.get("/:id/profile-summary", async (c) => {
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
if (!row) return c.json({ error: "Not found" }, 404);
// Owner-bypass: customer with a valid portal session for this pet's client
// is allowed to view their own pet's profile summary (GRO-2013).
let isOwner = false;
if (isGroomer) {
const ownerClientId = await resolveImpersonationClientId(db, c);
isOwner = !!ownerClientId && ownerClientId === row.clientId;
}
if (isGroomer && !isOwner) {
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
}
@@ -1,27 +0,0 @@
-- Migration: 0039_extend_pet_profile_columns_idempotent.sql
-- GRO-2033: re-register the temperament/medical/preferred-cuts columns from
-- 0034 with an idempotent ADD COLUMN IF NOT EXISTS + a monotonic journal
-- `when` (1780000000001), above the 0033 high-water mark (1779500000000)
-- and above the most recent applied migration 0038 (1780000000000).
--
-- 0034_extend_pet_profile_columns.sql was authored on 2026-05-28 with
-- `when` = 1751140800000 (2025-06-28) — *below* the 0033 high-water mark
-- of 1779500000000 (2026-05-23). drizzle-orm@0.38.4
-- (pg-core/dialect.js#migrate) only applies a migration when
-- `migration.folderMillis > lastDbMigration.created_at`, so on prod —
-- whose last applied entry was 0033 at created_at=1779500000000 — 0034
-- was silently skipped, leaving `pets.temperament_score` (and friends)
-- missing. The migrate Job still exits 0 ("migrations applied
-- successfully!") because the journal high watermark *was* advanced by
-- 0038, but no schema change ever ran for 0034. Seed/reset then crash on:
-- PostgresError: column "temperament_score" does not exist (42703)
--
-- Same pattern as GRO-1999 (0037 → 0038): do NOT modify 0034 in-place
-- (UAT/dev have already applied it via their lower watermarks). Add a
-- new idempotent migration with a monotonic `when` instead so existing
-- DBs apply it cleanly and fresh DBs are a no-op-after-no-op.
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "temperament_score" integer;
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "temperament_flags" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "medical_alerts" jsonb DEFAULT '[]';
ALTER TABLE "pets" ADD COLUMN IF NOT EXISTS "preferred_cuts" jsonb DEFAULT '[]';
@@ -1,26 +0,0 @@
-- Migration: 0040_register_missing_coat_type_values.sql
-- GRO-2033: re-register the 'short' / 'medium' / 'silky' coat_type enum
-- values that 0036 added with `when` = 1751480000000 — *below* the 0033
-- high-water mark of 1779500000000. drizzle-orm@0.38.4
-- (pg-core/dialect.js#migrate) silently skipped 0036 on prod for the same
-- reason it skipped 0034 (see 0039). 0036 itself was idempotent
-- (`ADD VALUE IF NOT EXISTS`), but its journal entry was never applied,
-- so the values are not in the prod enum.
--
-- Same pattern as GRO-1999 (0037 → 0038) and 0039: do NOT modify 0036 in
-- place. Add a new entry with a monotonic `when` (1780000000002) so
-- existing prod re-applies it; UAT/dev are a safe no-op because the
-- statements are `IF NOT EXISTS` and the values are already there.
--
-- Postgres restriction: `ALTER TYPE ... ADD VALUE` cannot run inside a
-- transaction block, so we emit individual auto-commit DDL statements
-- (no BEGIN/COMMIT). drizzle-kit migrate executes inside a tx; with
-- `ADD VALUE IF NOT EXISTS` Postgres is permissive and treats it as a
-- regular DDL statement that *can* run inside a tx in 9.6+ when no new
-- value is actually added. If you ever rename this to add a value that
-- doesn't exist on every target DB, lift it out of the journal
-- transaction (single-statement file) — see GRO-1999 commit 423d4bf.
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'short';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'medium';
ALTER TYPE "coat_type" ADD VALUE IF NOT EXISTS 'silky';
-14
View File
@@ -267,20 +267,6 @@
"when": 1780000000000,
"tag": "0038_register_extra_large_pet_size_category",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1780000000001,
"tag": "0039_extend_pet_profile_columns_idempotent",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1780000000002,
"tag": "0040_register_missing_coat_type_values",
"breakpoints": true
}
]
}
-285
View File
@@ -1,285 +0,0 @@
/**
* 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 ──────────────────────────────────────────────────────
//
// 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.
vi.mock("@groombook/db", () => {
const namedTable = (name: string) =>
new Proxy(
{ _name: name },
{
get(_t, p) {
if (p === "_name") return name;
return { table: name, column: p };
},
}
);
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: <T = unknown[]>(
onfulfilled?: ((value: unknown[]) => T | PromiseLike<T>) | null
) => Promise<T>;
}
function buildSelect(projection?: Record<string, unknown>): 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 [];
};
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);
},
};
return chain;
}
return {
getDb: () => ({
select: (projection?: Record<string, unknown>) => buildSelect(projection),
}),
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" }
),
};
});
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");
});
});
+1 -34
View File
@@ -23,23 +23,6 @@ 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),
@@ -129,24 +112,8 @@ 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");
if (!staffRow) {
return c.json({ error: "Unauthorized" }, 401);
}
const isGroomer = staffRow.role === "groomer";
const isGroomer = staffRow?.role === "groomer";
// Fetch the pet
const [pet] = await db.select().from(pets).where(eq(pets.id, petId));