Compare commits

...

4 Commits

Author SHA1 Message Date
Flea Flicker c2f4bca720 Merge origin/dev into flea/gro-2013-deployed-tree-conflicts
CI / Test (pull_request) Successful in 12s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 1m26s
# Conflicts:
#	UAT_PLAYBOOK.md
#	src/__tests__/petProfileSummary.test.ts
2026-06-01 19:54:22 +00:00
Paperclip a9ed681726 fix(pets): port owner-bypass into deployed tree (GRO-2013)
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Images (pull_request) Successful in 1m20s
The previous fix for GRO-2013 (customer cannot view own pet profile
summary) landed in apps/api/src/routes/pets.ts, which is dead code
in the Docker build path. The Dockerfile does COPY src/ + pnpm build
from the repo root, so apps/api/ is never copied into the image and
is not a pnpm-workspace member.

Port the owner-bypass into the deployed-tree handler src/routes/pets.ts:
- Add resolveImpersonationClientId(db, c) helper that reads the
  X-Impersonation-Session-Id header, validates the session is active
  and not expired, and returns its clientId (or null).
- Gate the existing groomer 403 in GET /:id/profile-summary so an
  owner (session.clientId === pet.clientId) bypasses the
  appointment-linkage check. This mirrors the already-reviewed logic
  from apps/api/src/routes/pets.ts:318-364.
- Cross-tenant access remains blocked: the bypass requires
  session.clientId === pet.clientId, and groomers with no portal
  session still 403 as before.

Tests (src/__tests__/petProfileSummary.test.ts — new file, mirroring
the dead-tree test pattern but pointing at the deployed handler):
- Customer with valid active session for pet's client → 200
- Customer with no header → 403
- Customer with session for a different client → 403
- Customer with expired session → 403
- Customer with ended (status != active) session → 403
- Customer with unknown session id → 403
- Manager does not need the impersonation header (regression)
- Groomer with linkage to pet's client still works (regression)
- Customer cannot view another client's pet (cross-tenant block)

Full @groombook/api test suite: 560 passed (39 files).

Note (out of scope): the apps/api/ duplicate tree is dead code
producing false-green coverage — recommend filing a separate tech-debt
issue to delete apps/api/ or wire it into the workspace, but not
blocking this fix on it.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 19:08:28 +00:00
Paperclip 7fe578aeef fix(pets): customer can view own pet profile summary (GRO-2013)
CI / Test (pull_request) Successful in 13s
CI / Lint & Typecheck (pull_request) Successful in 15s
CI / Build & Push Docker Images (pull_request) Successful in 1m8s
When a customer (e.g. uat-customer@groombook.dev) signs in via Better Auth
and calls GET /api/pets/{ownPetId}/profile-summary with their portal
session header, the staff RBAC middleware auto-provisions a 'groomer'
staff row for them (rbac.ts) and the profile-summary route's
groomerLinkageCheck then denies the request with 403 Forbidden, because
the auto-provisioned customer-as-groomer has no appointment linkage.

This adds an owner-bypass: when a groomer-role staff row is making the
request with a valid X-Impersonation-Session-Id header, and the resolved
impersonation session's clientId matches the pet's clientId, we treat
the caller as the pet's owner and skip the groomerLinkageCheck.

The bypass is intentionally scoped to the profile-summary endpoint and
to the existing portal session mechanism (no new roles, no staff-row
shape changes). Cross-tenant access is still blocked because the
bypass requires session.clientId === pet.clientId.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 17:42:59 +00:00
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
2 changed files with 480 additions and 156 deletions
+439 -155
View File
@@ -1,23 +1,37 @@
/** /**
* GET /pets/:id/profile-summary tests * Pet Profile Summary Tests
* *
* GRO-2014 regression coverage: * Covers GET /api/pets/:id/profile-summary in the deployed tree (root src/).
* - Empty-body 500 must never escape the route — the onError handler *
* converts unhandled errors into a structured JSON 500. * Two suites share one mock harness:
* - Malformed UUIDs must return 404 (not 500 via a Postgres uuid cast). *
* - Missing staff context must return 401 (not TypeError on staffRow.id). * 1. GRO-2013 owner-bypass (the deployed-tree port of #135):
* - Pet not found must return 404. * A customer who is auto-provisioned as a `groomer` staff row by rbac.ts
* - Groomer with no appointment linkage must return 403. * (with no appointment linkage) may still read their own pet's summary
* - Manager and groomer with linkage must receive the summary body. * 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono"; import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js"; import type { AppEnv, StaffRow } from "../middleware/rbac.js";
// ─── Fixtures ──────────────────────────────────────────────────────────────── // ─── Staff fixtures ──────────────────────────────────────────────────────────
const MANAGER: StaffRow = { const MANAGER: StaffRow = {
id: "00000000-0000-0000-0000-0000000000aa", id: "staff-manager-id",
oidcSub: "oidc-manager-sub", oidcSub: "oidc-manager-sub",
userId: null, userId: null,
role: "manager", role: "manager",
@@ -32,7 +46,7 @@ const MANAGER: StaffRow = {
const GROOMER: StaffRow = { const GROOMER: StaffRow = {
...MANAGER, ...MANAGER,
id: "00000000-0000-0000-0000-0000000000bb", id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub", oidcSub: "oidc-groomer-sub",
role: "groomer", role: "groomer",
isSuperUser: false, isSuperUser: false,
@@ -40,186 +54,274 @@ const GROOMER: StaffRow = {
email: "groomer@example.com", email: "groomer@example.com",
}; };
const PET_UUID = "11111111-1111-1111-1111-111111111111"; /**
const CLIENT_UUID = "22222222-2222-2222-2222-222222222222"; * Mirrors the auto-provisioned "groomer" staff row rbac.ts creates for an
const UNKNOWN_PET_UUID = "00000000-0000-0000-0000-000000000001"; * OIDC user (e.g. uat-customer@groombook.dev) on first login: role=groomer,
* no appointment linkage.
const PET_ROW = { */
id: PET_UUID, const CUSTOMER_STAFF: StaffRow = {
clientId: CLIENT_UUID, ...MANAGER,
name: "Biscuit", id: "staff-customer-id",
species: "dog", oidcSub: null,
breed: "Beagle", userId: "user-customer-id",
coatType: "short", role: "groomer",
petSizeCategory: "medium", name: "UAT Customer",
weightKg: "12.50", email: "uat-customer@groombook.dev",
dateOfBirth: new Date("2020-01-01"),
}; };
// ─── Mutable DB state ───────────────────────────────────────────────────────── // ─── Mutable mock state ─────────────────────────────────────────────────────
interface DbState { const CLIENT_ID = "c0000001-0000-0000-0000-000000000001";
petRow: typeof PET_ROW | null; const PET_ID = "c0000001-0000-0000-0000-000000000002";
linkageRow: { id: string } | null; const OTHER_CLIENT_PET_ID = "c0000002-0000-0000-0000-000000000099";
recentHistory: Array<Record<string, unknown>>; const UNKNOWN_PET_UUID = "00000000-0000-0000-0000-000000000001";
visitCount: number;
upcoming: Record<string, unknown> | null;
throwOnPetSelect: boolean;
}
let dbState: DbState; const futureDate = () => new Date(Date.now() + 30 * 60_000);
const pastDate = () => new Date(Date.now() - 5 * 60_000);
function resetDb() { function makePet(overrides: Record<string, unknown> = {}) {
dbState = { return {
petRow: { ...PET_ROW }, id: PET_ID,
linkageRow: { id: "appt-link" }, clientId: CLIENT_ID,
recentHistory: [], name: "Biscuit",
visitCount: 0, species: "dog",
upcoming: null, breed: "Golden Retriever",
throwOnPetSelect: false, 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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown>[];
let appointmentsTable: Record<string, unknown>[];
let servicesTable: Record<string, unknown>[];
let sessionsTable: Record<string, unknown>[];
// 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 // A queued entry of `{ table: "pets", rows: null, throw: "..." }` tells the
// it's projecting so we can return the right mocked rows. We thread that state // mock to throw instead of returning rows — used by the GRO-2014 "JSON
// through a per-call object whose chain methods all return `this`. The chain // envelope on downstream error" test. Any other queued entry with `rows`
// is also `then`-able so any `await` position resolves to the 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", () => { vi.mock("@groombook/db", () => {
const namedTable = (name: string) => function makeTable(name: string) {
new Proxy( return new Proxy(
{ _name: name }, { _name: name },
{ {
get(_t, p) { get(target, prop) {
if (p === "_name") return name; if (prop === "_name") return name;
return { table: name, column: p }; 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: <T = unknown[]>(
onfulfilled?: ((value: unknown[]) => T | PromiseLike<T>) | null
) => Promise<T>;
} }
function buildSelect(projection?: Record<string, unknown>): ChainLike { function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
let targetTable = ""; const queryString = _strings[0];
return {
const resolveRows = (): unknown[] => { queryChunks: [queryString],
if (targetTable === "pets") { as: (alias: string) => ({
if (dbState.throwOnPetSelect) { queryChunks: [queryString],
throw new Error("simulated postgres uuid cast failure"); fieldAlias: alias,
} getSQL() { return this.queryChunks; },
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 = { function takeQueuedRows(tableName: string): unknown[] {
from(table) { const next = selectQueue.shift();
targetTable = table._name; if (next && next.table === tableName) {
return chain; if (next.throw) {
}, throw new Error(next.throw);
where() { }
return chain; return next.rows ?? [];
}, }
innerJoin() { return [];
return chain; }
},
leftJoin() {
return chain;
},
orderBy() {
return chain;
},
limit() {
return chain;
},
then(onfulfilled) {
return Promise.resolve(resolveRows()).then(onfulfilled ?? undefined);
},
};
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 { return {
getDb: () => ({ getDb: () => ({
select: (projection?: Record<string, unknown>) => buildSelect(projection), select: (_cols?: Record<string, unknown>) => ({
from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")),
}),
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
}), }),
pets, pets: makeTable("pets"),
appointments, appointments: makeTable("appointments"),
services, staff: makeTable("staff"),
staff, services: makeTable("services"),
and: vi.fn(() => ({ _op: "and" })), impersonationSessions: makeTable("impersonationSessions"),
or: vi.fn(() => ({ _op: "or" })), and: vi.fn((..._args: unknown[]) => ({})),
eq: vi.fn(() => ({ _op: "eq" })), desc: vi.fn((c: unknown) => c),
desc: vi.fn((arg: unknown) => arg), eq: vi.fn((_a: unknown, _b: unknown) => ({})),
exists: vi.fn((arg: unknown) => arg), exists: vi.fn(() => true),
sql: Object.assign( or: vi.fn((..._args: unknown[]) => ({})),
() => ({ _op: "sql" }), sql: sqlMock,
{ [Symbol.toPrimitive]: () => "sql" }
),
}; };
}); });
vi.mock("../lib/s3.js", () => ({ vi.mock("../lib/s3.js", () => ({
getPresignedUploadUrl: vi.fn().mockResolvedValue("https://example.com/put"), getPresignedUploadUrl: vi.fn(),
getPresignedGetUrl: vi.fn().mockResolvedValue("https://example.com/get"), getPresignedGetUrl: vi.fn(),
deleteObject: vi.fn().mockResolvedValue(undefined), deleteObject: vi.fn(),
})); }));
// ─── Import after mocks are set up ──────────────────────────────────────────
const { petsRouter } = await import("../routes/pets.js"); const { petsRouter } = await import("../routes/pets.js");
// ─── App builder ──────────────────────────────────────────────────────────── // ─── App builder ────────────────────────────────────────────────────────────
function buildApp(staffRow: StaffRow | null) { function buildApp(staffRow: StaffRow | null) {
const app = new Hono<AppEnv>(); const app = new Hono<AppEnv>();
app.use("*", async (c, next) => { 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(); await next();
}); });
app.route("/pets", petsRouter); app.route("/pets", petsRouter);
return app; return app;
} }
// ─── Reset before each test ─────────────────────────────────────────────────
beforeEach(() => { beforeEach(() => {
resetDb(); resetMock();
vi.clearAllMocks(); 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 () => { it("returns 404 (not 500) for a malformed UUID path param", async () => {
const app = buildApp(MANAGER); const app = buildApp(MANAGER);
const res = await app.request("/pets/not-a-uuid/profile-summary"); 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 () => { it("returns 404 when authenticated and pet does not exist", async () => {
dbState.petRow = null; enqueue("pets", []);
const app = buildApp(MANAGER); const app = buildApp(MANAGER);
const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`); const res = await app.request(`/pets/${UNKNOWN_PET_UUID}/profile-summary`);
expect(res.status).toBe(404); 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 () => { 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 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); expect(res.status).toBe(403);
const body = (await res.json()) as { error: string }; const body = (await res.json()) as { error: string };
expect(body.error).toBe("Forbidden"); expect(body.error).toBe("Forbidden");
}); });
it("returns 200 with summary for a manager (no groomer linkage check)", async () => { 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 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); expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>; const body = (await res.json()) as Record<string, unknown>;
expect(body.id).toBe(PET_UUID); expect(body.id).toBe(PET_ID);
expect(body.name).toBe("Biscuit"); expect(body.name).toBe("Biscuit");
expect(body.visitCount).toBe(0); expect(body.visitCount).toBe(1);
expect(body.upcomingAppointment).toBeNull(); 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 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); expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>; const body = (await res.json()) as Record<string, unknown>;
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 () => { 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 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); expect(res.status).toBe(500);
const body = (await res.json()) as { error: string }; const body = (await res.json()) as { error: string };
expect(body.error).toBe("Internal Server Error"); 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);
});
});
+41 -1
View File
@@ -7,6 +7,7 @@ import {
eq, eq,
exists, exists,
getDb, getDb,
impersonationSessions,
or, or,
pets, pets,
appointments, appointments,
@@ -126,6 +127,35 @@ petsRouter.get("/:id", async (c) => {
return c.json(row); 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<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;
}
petsRouter.get("/:id/profile-summary", async (c) => { petsRouter.get("/:id/profile-summary", async (c) => {
const db = getDb(); const db = getDb();
const petId = c.req.param("id"); 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)); const [pet] = await db.select().from(pets).where(eq(pets.id, petId));
if (!pet) return c.json({ error: "Not found" }, 404); 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) { 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 const [linkage] = await db
.select({ id: appointments.id }) .select({ id: appointments.id })
.from(appointments) .from(appointments)