632 lines
23 KiB
TypeScript
632 lines
23 KiB
TypeScript
/**
|
|
* Pet Profile Summary Tests
|
|
*
|
|
* Covers GET /api/pets/:id/profile-summary in the deployed tree (root src/).
|
|
*
|
|
* Two suites share one mock harness:
|
|
*
|
|
* 1. GRO-2013 owner-bypass (the deployed-tree port of #135):
|
|
* A customer who is auto-provisioned as a `groomer` staff row by rbac.ts
|
|
* (with no appointment linkage) may still read their own pet's summary
|
|
* 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 { Hono } from "hono";
|
|
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
|
|
|
// ─── 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 = {
|
|
...MANAGER,
|
|
id: "staff-groomer-id",
|
|
oidcSub: "oidc-groomer-sub",
|
|
role: "groomer",
|
|
isSuperUser: false,
|
|
name: "Groomer Gary",
|
|
email: "groomer@example.com",
|
|
};
|
|
|
|
/**
|
|
* Mirrors the auto-provisioned "groomer" staff row rbac.ts creates for an
|
|
* OIDC user (e.g. uat-customer@groombook.dev) on first login: role=groomer,
|
|
* no appointment linkage.
|
|
*/
|
|
const CUSTOMER_STAFF: StaffRow = {
|
|
...MANAGER,
|
|
id: "staff-customer-id",
|
|
oidcSub: null,
|
|
userId: "user-customer-id",
|
|
role: "groomer",
|
|
name: "UAT Customer",
|
|
email: "uat-customer@groombook.dev",
|
|
};
|
|
|
|
// ─── Mutable mock state ─────────────────────────────────────────────────────
|
|
|
|
const CLIENT_ID = "c0000001-0000-0000-0000-000000000001";
|
|
const PET_ID = "c0000001-0000-0000-0000-000000000002";
|
|
const OTHER_CLIENT_PET_ID = "c0000002-0000-0000-0000-000000000099";
|
|
const UNKNOWN_PET_UUID = "00000000-0000-0000-0000-000000000001";
|
|
|
|
const futureDate = () => new Date(Date.now() + 30 * 60_000);
|
|
const pastDate = () => new Date(Date.now() - 5 * 60_000);
|
|
|
|
function makePet(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
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: {},
|
|
petSizeCategory: "large",
|
|
coatType: "double",
|
|
photoKey: null,
|
|
photoUploadedAt: null,
|
|
createdAt: new Date("2024-01-01"),
|
|
updatedAt: new Date("2024-01-01"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
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 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 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()).
|
|
//
|
|
// A queued entry of `{ table: "pets", rows: null, throw: "..." }` tells the
|
|
// mock to throw instead of returning rows — used by the GRO-2014 "JSON
|
|
// envelope on downstream error" test. Any other queued entry with `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;
|
|
}> = [];
|
|
|
|
// Captured `db.insert(table).values(vals)` calls. Mirrors the pattern from
|
|
// src/__tests__/impersonation.test.ts so the GRO-2063 audit row assertions
|
|
// can inspect what the route tried to write without needing a real DB.
|
|
let insertCapture: Array<{ table: string; vals: Record<string, unknown> }> = [];
|
|
|
|
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()];
|
|
sessionsTable = [makeSession()];
|
|
selectQueue = [];
|
|
insertCapture = [];
|
|
}
|
|
|
|
// ─── Module mocks ───────────────────────────────────────────────────────────
|
|
|
|
vi.mock("@groombook/db", () => {
|
|
function makeTable(name: string) {
|
|
return new Proxy(
|
|
{ _name: name },
|
|
{
|
|
get(target, prop) {
|
|
if (prop === "_name") return name;
|
|
if (prop === "$inferSelect") return {};
|
|
return { table: name, column: prop };
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
function sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
|
|
const queryString = _strings[0];
|
|
return {
|
|
queryChunks: [queryString],
|
|
as: (alias: string) => ({
|
|
queryChunks: [queryString],
|
|
fieldAlias: alias,
|
|
getSQL() { return this.queryChunks; },
|
|
}),
|
|
};
|
|
}
|
|
|
|
function takeQueuedRows(tableName: string): unknown[] {
|
|
const next = selectQueue.shift();
|
|
if (next && next.table === tableName) {
|
|
if (next.throw) {
|
|
throw new Error(next.throw);
|
|
}
|
|
return next.rows ?? [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// 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 {
|
|
getDb: () => ({
|
|
select: (_cols?: Record<string, unknown>) => ({
|
|
from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")),
|
|
}),
|
|
insert: (table: { _name?: string }) => ({
|
|
values: (vals: Record<string, unknown>) => {
|
|
insertCapture.push({ table: table._name ?? "unknown", vals });
|
|
return { returning: () => [{}] };
|
|
},
|
|
}),
|
|
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
|
|
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
|
|
}),
|
|
pets: makeTable("pets"),
|
|
appointments: makeTable("appointments"),
|
|
staff: makeTable("staff"),
|
|
services: makeTable("services"),
|
|
impersonationSessions: makeTable("impersonationSessions"),
|
|
impersonationAuditLogs: makeTable("impersonation_audit_logs"),
|
|
and: vi.fn((..._args: unknown[]) => ({})),
|
|
desc: vi.fn((c: unknown) => c),
|
|
eq: vi.fn((_a: unknown, _b: unknown) => ({})),
|
|
exists: vi.fn(() => true),
|
|
or: vi.fn((..._args: unknown[]) => ({})),
|
|
sql: sqlMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("../lib/s3.js", () => ({
|
|
getPresignedUploadUrl: vi.fn(),
|
|
getPresignedGetUrl: vi.fn(),
|
|
deleteObject: vi.fn(),
|
|
}));
|
|
|
|
// ─── Import after mocks are set up ──────────────────────────────────────────
|
|
|
|
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("jwtPayload", { sub: staffRow.oidcSub ?? staffRow.userId ?? "" });
|
|
c.set("staff", staffRow);
|
|
}
|
|
await next();
|
|
});
|
|
app.route("/pets", petsRouter);
|
|
return app;
|
|
}
|
|
|
|
// ─── Reset before each test ─────────────────────────────────────────────────
|
|
|
|
beforeEach(() => {
|
|
resetMock();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ─── GRO-2014 error-handling suite ──────────────────────────────────────────
|
|
|
|
describe("GET /: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 () => {
|
|
enqueue("pets", []);
|
|
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 () => {
|
|
enqueue("pets", petsTable);
|
|
enqueue("appointments", []); // linkage check returns empty → 403
|
|
const app = buildApp(GROOMER);
|
|
const res = await app.request(`/pets/${PET_ID}/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 () => {
|
|
enqueue("pets", petsTable);
|
|
enqueue("appointments", appointmentsTable); // history
|
|
enqueue("appointments", [{ count: 1 }]); // visit count
|
|
enqueue("appointments", []); // upcoming (none)
|
|
const app = buildApp(MANAGER);
|
|
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
expect(body.id).toBe(PET_ID);
|
|
expect(body.name).toBe("Biscuit");
|
|
expect(body.visitCount).toBe(1);
|
|
expect(body.upcomingAppointment).toBeNull();
|
|
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
|
|
});
|
|
|
|
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 res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as Record<string, unknown>;
|
|
expect(body.id).toBe(PET_ID);
|
|
});
|
|
|
|
it("returns a JSON envelope (not empty body) when a downstream query throws", async () => {
|
|
enqueueThrow("pets", "simulated postgres uuid cast failure");
|
|
const app = buildApp(MANAGER);
|
|
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
|
expect(res.status).toBe(500);
|
|
const body = (await res.json()) as { error: string };
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ─── GRO-2063 owner-bypass audit write ──────────────────────────────────────
|
|
|
|
describe("GET /:id/profile-summary — owner-bypass audit row (GRO-2063)", () => {
|
|
it("writes exactly one audit row on the owner-bypass success path", async () => {
|
|
enqueue("pets", petsTable);
|
|
enqueue("impersonationSessions", sessionsTable); // valid active session for CLIENT_ID
|
|
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 auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
|
|
expect(auditInserts).toHaveLength(1);
|
|
const vals = auditInserts[0]!.vals;
|
|
expect(vals.action).toBe("read_profile_summary");
|
|
expect(vals.sessionId).toBe("sess-owner");
|
|
expect(vals.pageVisited).toBe(`/pets/${PET_ID}/profile-summary`);
|
|
expect(vals.metadata).toEqual({
|
|
petId: PET_ID,
|
|
actorStaffId: CUSTOMER_STAFF.id,
|
|
});
|
|
});
|
|
|
|
it("does NOT write an audit row on the normal groomer-linkage success path", async () => {
|
|
// GROOMER is a "real" groomer with appointment linkage, NOT the
|
|
// auto-provisioned customer-as-groomer. No impersonation header is
|
|
// present, so the owner-bypass branch never executes.
|
|
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);
|
|
|
|
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
|
|
expect(auditInserts).toHaveLength(0);
|
|
});
|
|
|
|
it("does NOT write an audit row when the owner-bypass attempt is denied (cross-tenant)", async () => {
|
|
// Customer has a valid session but it points at a different client.
|
|
// isOwner=false, falls through to groomer linkage check, returns 403.
|
|
enqueue("pets", [
|
|
makePet({ id: OTHER_CLIENT_PET_ID, clientId: "c0000002-0000-0000-0000-000000000002" }),
|
|
]);
|
|
enqueue("impersonationSessions", sessionsTable); // session is 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);
|
|
|
|
const auditInserts = insertCapture.filter((c) => c.table === "impersonation_audit_logs");
|
|
expect(auditInserts).toHaveLength(0);
|
|
});
|
|
});
|