From 3e547b8568653876a2cfbef28bcec9ab516af00a Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 1 Jun 2026 14:02:38 +0000 Subject: [PATCH 1/3] fix(docker): bake pnpm via npm to remove Corepack runtime downloads (GRO-1981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GRO-1983 fast restoration swapped Corepack's pnpm shim for a real `npm install -g pnpm@9.15.4` binary, which is the right move. But the GRO-1997 evidence gate still showed the first `reset-demo-data` pod (...-nh7vg) hitting `getaddrinfo EAI_AGAIN registry.npmjs.org` before a retry succeeded — the cache was writable, the cold-cache registry download wasn't eliminated. This is the durable fix: 1. `ENV COREPACK_ENABLE_DOWNLOAD_FALLBACK=0` in `base` and `runner`: defence in depth so a Corepack shim can never silently re-download pnpm, even if it is somehow re-introduced. 2. `ENV HOME=/tmp` in the `migrate`, `seed`, and `reset` stages: under `readOnlyRootFilesystem: true` + `runAsUser: 1000`, the default HOME path is read-only, and pnpm fails the first time it tries to write a config or state file. The job pods already mount a writable emptyDir at `/tmp`; point HOME there. 3. CI smoke tests for `seed` and `reset` images (matching the existing `migrate` smoke): point `registry.npmjs.org` at 127.0.0.1 in a throwaway container, assert `which pnpm` resolves to `/usr/local/bin/pnpm` (real binary, not shim), and that `pnpm --version` succeeds without network egress. If Corepack ever sneaks back in, CI catches it on every PR. The vestigial `RUN mkdir -p /home/node/.cache/node/corepack` in the `builder` stage (mentioned in the spec) was already removed in GRO-1909 (commit 0a3eb8a), so nothing to do there. Follow-on cleanup of the per-job `COREPACK_HOME` env vars and `node-cache` emptyDir mounts in `groombook/infra` is intentionally deferred to a coordinated infra PR once the new image is deployed — keeping the existing infra in place during the transition avoids a flag-day. GRO-1985, hardening follow-up to GRO-1984 / GRO-1983. Closes parent: GRO-1981. Co-Authored-By: Paperclip --- .gitea/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ Dockerfile | 14 +++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9103390..d848d3b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -156,3 +156,32 @@ 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 ✓" diff --git a/Dockerfile b/Dockerfile index 5fea669..a77a7df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,12 @@ 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. +# Jobs. GRO-1983 / GRO-1889 / GRO-1909 / GRO-1981 / GRO-1985. 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 @@ -26,6 +30,8 @@ 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 @@ -46,12 +52,18 @@ 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"] From 2251a172e39ae2d148e3bddac99556116c9536c3 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 1 Jun 2026 17:11:12 +0000 Subject: [PATCH 2/3] docs(UAT_PLAYBOOK): document canonical source-of-truth for UAT seed passwords (GRO-2000) (#132) --- UAT_PLAYBOOK.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index c5bdf04..f0e1037 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -19,6 +19,27 @@ GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet - OIDC authentication provider configured - Seed data present (clients, pets, services, staff) +### Source of truth for UAT passwords (GRO-2000) + +The `UAT_SUPER_PASSWORD` / `UAT_GROOMER_PASSWORD` / `UAT_TESTER_PASSWORD` / `UAT_CUSTOMER_PASSWORD` env vars the test orchestrator uses **must** be pulled from the live `seed-uat-passwords` Secret in the UAT cluster — never from a captured shell value, a previous run's `.env`, or a copy of the SealedSecret committed before the latest rotation. + +**Canonical recipe** (works from any host with `kubectl` + cluster credentials): + +```bash +SUPER=$(kubectl get secret seed-uat-passwords -n groombook-uat \ + -o jsonpath='{.data.super-password}' | base64 -d) +GROOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \ + -o jsonpath='{.data.groomer-password}' | base64 -d) +TESTER=$(kubectl get secret seed-uat-passwords -n groombook-uat \ + -o jsonpath='{.data.tester-password}' | base64 -d) +CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \ + -o jsonpath='{.data.customer-password}' | base64 -d) +``` + +**Why:** the Bitnami SealedSecret `apps/overlays/uat/ss-seed-uat-passwords.yaml` (in `groombook/infra`) is the single source of truth. The UAT `reset-demo-data` CronJob re-hashes these values into the `account` table on every run (idempotent — GRO-1977). A captured env var from a previous generation will not match the current hash, producing 401 `INVALID_EMAIL_OR_PASSWORD`. If the live login still 401s after pulling from the SealedSecret, the seed Job is stale — trigger `kubectl create job --from=cronjob/reset-demo-data -n groombook-uat manual-seed-$$` and retry. + +**How to apply:** at the start of every UAT run that touches TC-API-1.4 / 1.5 / 1.6 / 1.7 / 3.18 / 3.21 / 3.23, refresh these four env vars from the cluster before issuing the sign-in request. + ## Test Cases ### 4.0 Health Check From fee62c895d8eeb3d987a15e0a98754114e45f097 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 1 Jun 2026 18:16:29 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(api):=20GRO-2014=20=E2=80=94=20profile-?= =?UTF-8?q?summary=20500=20=E2=86=92=20404/401/JSON-500=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 3 + src/__tests__/petProfileSummary.test.ts | 285 ++++++++++++++++++++++++ src/routes/pets.ts | 35 ++- 3 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/petProfileSummary.test.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index f0e1037..5860fbc 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -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) diff --git a/src/__tests__/petProfileSummary.test.ts b/src/__tests__/petProfileSummary.test.ts new file mode 100644 index 0000000..8a17d43 --- /dev/null +++ b/src/__tests__/petProfileSummary.test.ts @@ -0,0 +1,285 @@ +/** + * 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>; + visitCount: number; + upcoming: Record | 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: ( + 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 []; + }; + + 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) => 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(); + 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; + 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; + 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"); + }); +}); diff --git a/src/routes/pets.ts b/src/routes/pets.ts index ffe494c..9695af7 100644 --- a/src/routes/pets.ts +++ b/src/routes/pets.ts @@ -23,6 +23,23 @@ import { export const petsRouter = new Hono(); +// 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));