Files
api/apps/api/src/__tests__/petProfileSummary.test.ts
T
The Dogfather 9903b51931
CI / Test (push) Successful in 16s
CI / Lint & Typecheck (push) Failing after 14m15s
CI / Build & Push Docker Images (push) Has been skipped
fix(pets): customer can view own pet profile summary (GRO-2013) (#135)
Adds an owner-bypass in the profile-summary handler for customers signed in via Better Auth, using the existing X-Impersonation-Session-Id portal session header. When a groomer-role staff row carries a valid impersonation session whose clientId matches the pet's clientId, skip groomerLinkageCheck and serve the summary. Otherwise fall through to the existing linkage check.

Resolves a 403 Forbidden where the customer (auto-provisioned by resolveStaffMiddleware as a 'groomer' staff row with no appointment linkage) could not read their own pet's profile.

Scope: GRO-2013 profile-summary endpoint only — no rbac.ts/schema/Dockerfile changes.

Tests: 6 new cases (owner-bypass, no-header, cross-tenant, expired, manager regression, linked-groomer regression); 294/294 pass.

UAT_PLAYBOOK.md: TC-API-3.19a/b/c.

Closes GRO-2013.

Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-01 18:40:25 +00:00

517 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
import { petsRouter } from "../routes/pets.js";
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
const MANAGER: StaffRow = {
id: "staff-manager-id",
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 = {
id: "staff-groomer-id",
oidcSub: "oidc-groomer-sub",
userId: null,
role: "groomer",
isSuperUser: false,
name: "Groomer McGroome",
email: "groomer@example.com",
active: true,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
};
// ─── Mutable mock state ───────────────────────────────────────────────────────
const CLIENT_ID = "client-uuid-summary";
const PET_ID = "pet-uuid-summary";
interface MockState {
pets: Record<string, unknown>[];
appointments: Record<string, unknown>[];
groomingLogs: Record<string, unknown>[];
staffMembers: Record<string, unknown>[];
services: Record<string, unknown>[];
impersonationSessions: Record<string, unknown>[];
}
let mock: MockState;
function resetMock() {
mock = {
pets: [{
id: PET_ID,
clientId: CLIENT_ID,
name: "Biscuit",
species: "dog",
breed: "Golden Retriever",
weightKg: "30.00",
dateOfBirth: null,
healthAlerts: null,
groomingNotes: null,
cutStyle: null,
shampooPreference: null,
specialCareNotes: null,
customFields: {},
photoKey: null,
photoUploadedAt: null,
image: null,
coatType: "double",
temperamentScore: 3,
temperamentFlags: ["gentle"],
medicalAlerts: [],
preferredCuts: ["puppy cut"],
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
}],
appointments: [
{
id: "appt-completed-1",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-1",
staffId: "staff-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"),
},
{
id: "appt-upcoming-1",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-2",
staffId: "staff-groomer-id",
batherStaffId: null,
status: "confirmed",
startTime: new Date("2024-12-01T09:00:00Z"),
endTime: new Date("2024-12-01T11:00:00Z"),
notes: null,
priceCents: 6500,
seriesId: null,
seriesIndex: null,
groupId: null,
confirmationStatus: "confirmed",
confirmedAt: null,
cancelledAt: null,
confirmationToken: null,
customerNotes: null,
createdAt: new Date("2024-11-01"),
updatedAt: new Date("2024-11-01"),
},
],
groomingLogs: [
{
id: "log-1",
petId: PET_ID,
appointmentId: "appt-completed-1",
staffId: "staff-groomer-id",
cutStyle: "puppy cut",
productsUsed: "oatmeal shampoo",
notes: "Trimmed nails",
groomedAt: new Date("2024-06-01T10:00:00Z"),
createdAt: new Date("2024-06-01T10:00:00Z"),
},
],
staffMembers: [
{
id: "staff-groomer-id",
name: "Groomer McGroome",
email: "groomer@example.com",
role: "groomer",
isSuperUser: false,
active: true,
oidcSub: "oidc-groomer-sub",
userId: null,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "staff-manager-id",
name: "Manager McManager",
email: "manager@example.com",
role: "manager",
isSuperUser: true,
active: true,
oidcSub: "oidc-manager-sub",
userId: null,
icalToken: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
services: [
{ 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"),
},
],
};
}
vi.mock("../db/index.js", () => {
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
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>> = {};
function makeChainable(rows: unknown[]) {
const arr = rows as unknown[];
return new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit" || prop === "leftJoin" || prop === "from") {
return () => makeChainable(target);
}
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
return target[prop];
},
});
}
// sql mock: returns an object with .as() so drizzle's select() can alias it
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
const queryString = _strings[0];
const asFn = (alias: string) => ({
sql: { queryChunks: [_strings[0]] },
fieldAlias: alias,
getSQL() { return this.sql; },
});
return { queryChunks: [queryString], as: asFn };
}
return {
getDb: () => ({
select: (cols?: Record<string, unknown>) => {
selectedColumns = {};
if (cols) {
// Inspect cols to find sql-aliased expressions and their aliases
for (const [alias, expr] of Object.entries(cols)) {
if (expr && typeof expr === "object" && "as" in expr && typeof (expr as Record<string, unknown>).as === "function") {
const aliased = (expr as { as: (a: string) => { fieldAlias: string; sql: unknown } }).as(alias);
// Detect count(*) queries
if (typeof aliased.sql === "object" && aliased.sql !== null && "queryChunks" in (aliased.sql as Record<string, unknown>) && String((aliased.sql as { queryChunks?: unknown[] }).queryChunks).includes("count")) {
// Store count query intent — we'll resolve it in from()
if (!selectedColumns["appointments"]) selectedColumns["appointments"] = {};
selectedColumns["appointments"][alias] = { _isCountQuery: true };
}
}
}
}
return {
from: (table: unknown) => {
const name = (table as { _name?: string })._name;
const tableCols = selectedColumns[name] || {};
// If this table has a count query, return computed count result
const countQueryEntry = Object.entries(tableCols).find(([, v]) =>
typeof v === "object" && v !== null && "_isCountQuery" in v
);
if (countQueryEntry) {
const [countAlias] = countQueryEntry;
const count = (name === "appointments" ? mock.appointments : [])
.filter((row: Record<string, unknown>) => row.status === "completed").length;
return makeChainable([{ [countAlias]: count }]);
}
if (name === "pets") return makeChainable(mock.pets);
if (name === "appointments") return makeChainable(mock.appointments);
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([]);
},
};
},
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
}),
pets,
appointments,
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 })),
exists: vi.fn(() => true),
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
or: vi.fn((a: unknown, b: unknown) => [a, b]),
sql: sqlMock,
};
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeApp(staff: StaffRow = MANAGER) {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("staff", staff);
await next();
});
return app.route("/pets", petsRouter);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("GET /:id/profile-summary", () => {
beforeEach(resetMock);
it("returns 404 for non-existent pet", async () => {
const app = makeApp();
mock.pets = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(404);
});
it("returns 403 for groomer with no pet linkage", async () => {
const app = makeApp(GROOMER);
// Groomer has no linkage to this pet's client — clear appointments
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(403);
});
it("returns complete aggregated profile for manager", async () => {
const app = makeApp(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.species).toBe("dog");
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
expect(body.lastVisitDate).toBeTruthy();
expect(body.visitCount).toBeGreaterThanOrEqual(0);
});
it("groomer with pet linkage returns 200", async () => {
const app = makeApp(GROOMER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
});
it("recentGroomingHistory is limited to 10 entries", async () => {
const app = makeApp(MANAGER);
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.recentGroomingHistory.length).toBeLessThanOrEqual(10);
});
it("returns null upcomingAppointment when none scheduled", async () => {
const app = makeApp(MANAGER);
mock.appointments = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.upcomingAppointment).toBeNull();
});
});
describe("GET /:id/profile-summary — visitCount", () => {
beforeEach(resetMock);
it("returns visitCount >= 2 when pet has 2+ completed appointments", async () => {
const app = makeApp(MANAGER);
// Add a second completed appointment
mock.appointments = [
...mock.appointments,
{
id: "appt-completed-2",
clientId: CLIENT_ID,
petId: PET_ID,
serviceId: "service-1",
staffId: "staff-groomer-id",
batherStaffId: null,
status: "completed",
startTime: new Date("2024-07-01T09:00:00Z"),
endTime: new Date("2024-07-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-06-15"),
updatedAt: new Date("2024-06-15"),
},
];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.visitCount).toBeGreaterThanOrEqual(2);
});
it("returns visitCount = 0 when no completed appointments", async () => {
const app = makeApp(MANAGER);
mock.appointments = mock.appointments.map((a) => ({ ...a, status: "cancelled" }));
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.visitCount).toBe(0);
});
});
describe("GET /:id/profile-summary — empty history", () => {
beforeEach(resetMock);
it("returns empty history array when no grooming logs", async () => {
const app = makeApp(MANAGER);
mock.groomingLogs = [];
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
expect(res.status).toBe(200);
const body = await res.json();
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);
});
});