9903b51931
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>
517 lines
18 KiB
TypeScript
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);
|
|
});
|
|
}); |