Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 986710aa27 | |||
| 762d263016 |
@@ -125,9 +125,6 @@ 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.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.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.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.19a | Get pet profile summary — customer owner-bypass (GRO-2013) | Sign in as `uat-customer@groombook.dev`; `POST /api/portal/session-from-auth`; then `GET /api/pets/{ownPetId}/profile-summary` with header `X-Impersonation-Session-Id: {sessionId}` for either of the customer's seeded pets (`c0000001-0000-0000-0000-000000000002` UAT Pup Alpha, `c0000001-0000-0000-0000-000000000003` UAT Pup Beta) | 200 OK, aggregated profile returned (owner-bypass: customer with valid portal session for pet's clientId is allowed even though rbac.ts auto-provisions them as a `groomer` staff row with no appointment linkage) |
|
|
||||||
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
|
|
||||||
| TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) |
|
|
||||||
| 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.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.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"}` |
|
| 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"}` |
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ interface MockState {
|
|||||||
groomingLogs: Record<string, unknown>[];
|
groomingLogs: Record<string, unknown>[];
|
||||||
staffMembers: Record<string, unknown>[];
|
staffMembers: Record<string, unknown>[];
|
||||||
services: Record<string, unknown>[];
|
services: Record<string, unknown>[];
|
||||||
impersonationSessions: Record<string, unknown>[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mock: MockState;
|
let mock: MockState;
|
||||||
@@ -169,19 +168,6 @@ function resetMock() {
|
|||||||
{ id: "service-1", name: "Full Groom", description: null, basePriceCents: 6000, durationMinutes: 120, active: true, createdAt: new Date(), updatedAt: new Date() },
|
{ 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() },
|
{ 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"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +177,6 @@ vi.mock("../db/index.js", () => {
|
|||||||
const groomingVisitLogs = new Proxy({ _name: "groomingVisitLogs" }, { get: (t, p) => p === "_name" ? "groomingVisitLogs" : {} });
|
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 staff = new Proxy({ _name: "staff" }, { get: (t, p) => p === "_name" ? "staff" : {} });
|
||||||
const services = new Proxy({ _name: "services" }, { get: (t, p) => p === "_name" ? "services" : {} });
|
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
|
// Tracks { [tableName]: { [alias]: SQLExpression } } for the current select() call
|
||||||
let selectedColumns: Record<string, Record<string, unknown>> = {};
|
let selectedColumns: Record<string, Record<string, unknown>> = {};
|
||||||
@@ -263,7 +248,6 @@ vi.mock("../db/index.js", () => {
|
|||||||
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
|
if (name === "groomingVisitLogs") return makeChainable(mock.groomingLogs);
|
||||||
if (name === "staff") return makeChainable(mock.staffMembers);
|
if (name === "staff") return makeChainable(mock.staffMembers);
|
||||||
if (name === "services") return makeChainable(mock.services);
|
if (name === "services") return makeChainable(mock.services);
|
||||||
if (name === "impersonationSessions") return makeChainable(mock.impersonationSessions);
|
|
||||||
return makeChainable([]);
|
return makeChainable([]);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -277,7 +261,6 @@ vi.mock("../db/index.js", () => {
|
|||||||
groomingVisitLogs,
|
groomingVisitLogs,
|
||||||
staff,
|
staff,
|
||||||
services,
|
services,
|
||||||
impersonationSessions,
|
|
||||||
and: vi.fn((a: unknown, b: unknown) => [a, b]),
|
and: vi.fn((a: unknown, b: unknown) => [a, b]),
|
||||||
desc: vi.fn((c: unknown) => c),
|
desc: vi.fn((c: unknown) => c),
|
||||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||||
@@ -416,102 +399,4 @@ describe("GET /:id/profile-summary — empty history", () => {
|
|||||||
expect(body.recentGroomingHistory).toEqual([]);
|
expect(body.recentGroomingHistory).toEqual([]);
|
||||||
expect(body.lastVisitDate).toBeNull();
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, impersonationSessions, or, pets, appointments, staff, services, sql } from "../db/index.js";
|
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -307,38 +307,10 @@ async function groomerLinkageCheck(
|
|||||||
return !!linkage;
|
return !!linkage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /:id/profile-summary
|
* GET /:id/profile-summary
|
||||||
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
|
* Returns aggregated profile: basic pet fields + grooming history + visit stats + upcoming appointment.
|
||||||
* Groomer RBAC: same visibility rules as GET /:id.
|
* Groomer RBAC: same visibility rules as GET /:id.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
petsRouter.get("/:id/profile-summary", async (c) => {
|
petsRouter.get("/:id/profile-summary", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -349,15 +321,7 @@ petsRouter.get("/:id/profile-summary", async (c) => {
|
|||||||
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
|
const [row] = await db.select().from(pets).where(eq(pets.id, petId));
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
// Owner-bypass: customer with a valid portal session for this pet's client
|
|
||||||
// is allowed to view their own pet's profile summary (GRO-2013).
|
|
||||||
let isOwner = false;
|
|
||||||
if (isGroomer) {
|
if (isGroomer) {
|
||||||
const ownerClientId = await resolveImpersonationClientId(db, c);
|
|
||||||
isOwner = !!ownerClientId && ownerClientId === row.clientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGroomer && !isOwner) {
|
|
||||||
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
|
const hasLinkage = await groomerLinkageCheck(db, row.clientId, staffRow);
|
||||||
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
|
if (!hasLinkage) return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Pet Profile Summary Tests
|
* GET /pets/:id/profile-summary tests
|
||||||
*
|
*
|
||||||
* Covers GET /api/pets/:id/profile-summary in the deployed tree (root src/).
|
* GRO-2014 regression coverage:
|
||||||
*
|
* - Empty-body 500 must never escape the route — the onError handler
|
||||||
* Two suites share one mock harness:
|
* converts unhandled errors into a structured JSON 500.
|
||||||
*
|
* - Malformed UUIDs must return 404 (not 500 via a Postgres uuid cast).
|
||||||
* 1. GRO-2013 owner-bypass (the deployed-tree port of #135):
|
* - Missing staff context must return 401 (not TypeError on staffRow.id).
|
||||||
* A customer who is auto-provisioned as a `groomer` staff row by rbac.ts
|
* - Pet not found must return 404.
|
||||||
* (with no appointment linkage) may still read their own pet's summary
|
* - Groomer with no appointment linkage must return 403.
|
||||||
* when they supply a valid X-Impersonation-Session-Id whose clientId
|
* - Manager and groomer with linkage must receive the summary body.
|
||||||
* 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";
|
||||||
|
|
||||||
// ─── Staff fixtures ──────────────────────────────────────────────────────────
|
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const MANAGER: StaffRow = {
|
const MANAGER: StaffRow = {
|
||||||
id: "staff-manager-id",
|
id: "00000000-0000-0000-0000-0000000000aa",
|
||||||
oidcSub: "oidc-manager-sub",
|
oidcSub: "oidc-manager-sub",
|
||||||
userId: null,
|
userId: null,
|
||||||
role: "manager",
|
role: "manager",
|
||||||
@@ -46,7 +32,7 @@ const MANAGER: StaffRow = {
|
|||||||
|
|
||||||
const GROOMER: StaffRow = {
|
const GROOMER: StaffRow = {
|
||||||
...MANAGER,
|
...MANAGER,
|
||||||
id: "staff-groomer-id",
|
id: "00000000-0000-0000-0000-0000000000bb",
|
||||||
oidcSub: "oidc-groomer-sub",
|
oidcSub: "oidc-groomer-sub",
|
||||||
role: "groomer",
|
role: "groomer",
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
@@ -54,274 +40,186 @@ const GROOMER: StaffRow = {
|
|||||||
email: "groomer@example.com",
|
email: "groomer@example.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const PET_UUID = "11111111-1111-1111-1111-111111111111";
|
||||||
* Mirrors the auto-provisioned "groomer" staff row rbac.ts creates for an
|
const CLIENT_UUID = "22222222-2222-2222-2222-222222222222";
|
||||||
* 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 UNKNOWN_PET_UUID = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
const futureDate = () => new Date(Date.now() + 30 * 60_000);
|
const PET_ROW = {
|
||||||
const pastDate = () => new Date(Date.now() - 5 * 60_000);
|
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"),
|
||||||
|
};
|
||||||
|
|
||||||
function makePet(overrides: Record<string, unknown> = {}) {
|
// ─── Mutable DB state ─────────────────────────────────────────────────────────
|
||||||
return {
|
|
||||||
id: PET_ID,
|
interface DbState {
|
||||||
clientId: CLIENT_ID,
|
petRow: typeof PET_ROW | null;
|
||||||
name: "Biscuit",
|
linkageRow: { id: string } | null;
|
||||||
species: "dog",
|
recentHistory: Array<Record<string, unknown>>;
|
||||||
breed: "Golden Retriever",
|
visitCount: number;
|
||||||
weightKg: "30.00",
|
upcoming: Record<string, unknown> | null;
|
||||||
dateOfBirth: null,
|
throwOnPetSelect: boolean;
|
||||||
healthAlerts: null,
|
}
|
||||||
groomingNotes: null,
|
|
||||||
cutStyle: null,
|
let dbState: DbState;
|
||||||
shampooPreference: null,
|
|
||||||
specialCareNotes: null,
|
function resetDb() {
|
||||||
customFields: {},
|
dbState = {
|
||||||
petSizeCategory: "large",
|
petRow: { ...PET_ROW },
|
||||||
coatType: "double",
|
linkageRow: { id: "appt-link" },
|
||||||
photoKey: null,
|
recentHistory: [],
|
||||||
photoUploadedAt: null,
|
visitCount: 0,
|
||||||
createdAt: new Date("2024-01-01"),
|
upcoming: null,
|
||||||
updatedAt: new Date("2024-01-01"),
|
throwOnPetSelect: false,
|
||||||
...overrides,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeAppointment(overrides: Record<string, unknown> = {}) {
|
// ─── @groombook/db mock ──────────────────────────────────────────────────────
|
||||||
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()).
|
|
||||||
//
|
//
|
||||||
// A queued entry of `{ table: "pets", rows: null, throw: "..." }` tells the
|
// Each select chain needs to know which table it's targeting and which columns
|
||||||
// mock to throw instead of returning rows — used by the GRO-2014 "JSON
|
// it's projecting so we can return the right mocked rows. We thread that state
|
||||||
// envelope on downstream error" test. Any other queued entry with `rows`
|
// through a per-call object whose chain methods all return `this`. The chain
|
||||||
// resolves to those rows. An entry with `rows: []` returns an empty array
|
// is also `then`-able so any `await` position resolves to the rows.
|
||||||
// (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", () => {
|
||||||
function makeTable(name: string) {
|
const namedTable = (name: string) =>
|
||||||
return new Proxy(
|
new Proxy(
|
||||||
{ _name: name },
|
{ _name: name },
|
||||||
{
|
{
|
||||||
get(target, prop) {
|
get(_t, p) {
|
||||||
if (prop === "_name") return name;
|
if (p === "_name") return name;
|
||||||
if (prop === "$inferSelect") return {};
|
return { table: name, column: p };
|
||||||
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 sqlMock(_strings: TemplateStringsArray, ..._params: unknown[]) {
|
function buildSelect(projection?: Record<string, unknown>): ChainLike {
|
||||||
const queryString = _strings[0];
|
let targetTable = "";
|
||||||
return {
|
|
||||||
queryChunks: [queryString],
|
|
||||||
as: (alias: string) => ({
|
|
||||||
queryChunks: [queryString],
|
|
||||||
fieldAlias: alias,
|
|
||||||
getSQL() { return this.queryChunks; },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeQueuedRows(tableName: string): unknown[] {
|
const resolveRows = (): unknown[] => {
|
||||||
const next = selectQueue.shift();
|
if (targetTable === "pets") {
|
||||||
if (next && next.table === tableName) {
|
if (dbState.throwOnPetSelect) {
|
||||||
if (next.throw) {
|
throw new Error("simulated postgres uuid cast failure");
|
||||||
throw new Error(next.throw);
|
}
|
||||||
|
return dbState.petRow ? [dbState.petRow] : [];
|
||||||
}
|
}
|
||||||
return next.rows ?? [];
|
if (targetTable === "appointments") {
|
||||||
}
|
const keys = projection ? Object.keys(projection) : [];
|
||||||
return [];
|
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 [];
|
||||||
|
};
|
||||||
|
|
||||||
// Wrap a finalised result in a Proxy that exposes chainable methods
|
const chain: ChainLike = {
|
||||||
// and the resolved rows. Each call to a chainable method (where/orderBy/
|
from(table) {
|
||||||
// limit/...) returns the SAME rows so the route's natural await on the
|
targetTable = table._name;
|
||||||
// chain resolves to the queued data.
|
return chain;
|
||||||
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];
|
|
||||||
},
|
},
|
||||||
});
|
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 {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: (_cols?: Record<string, unknown>) => ({
|
select: (projection?: Record<string, unknown>) => buildSelect(projection),
|
||||||
from: (table: { _name?: string }) => wrapRows(takeQueuedRows(table._name ?? "")),
|
|
||||||
}),
|
|
||||||
insert: () => ({ values: () => ({ returning: () => [{}] }) }),
|
|
||||||
update: () => ({ set: () => ({ where: () => ({ returning: () => [{}] }) }) }),
|
|
||||||
delete: () => ({ where: () => ({ returning: () => [{}] }) }),
|
|
||||||
}),
|
}),
|
||||||
pets: makeTable("pets"),
|
pets,
|
||||||
appointments: makeTable("appointments"),
|
appointments,
|
||||||
staff: makeTable("staff"),
|
services,
|
||||||
services: makeTable("services"),
|
staff,
|
||||||
impersonationSessions: makeTable("impersonationSessions"),
|
and: vi.fn(() => ({ _op: "and" })),
|
||||||
and: vi.fn((..._args: unknown[]) => ({})),
|
or: vi.fn(() => ({ _op: "or" })),
|
||||||
desc: vi.fn((c: unknown) => c),
|
eq: vi.fn(() => ({ _op: "eq" })),
|
||||||
eq: vi.fn((_a: unknown, _b: unknown) => ({})),
|
desc: vi.fn((arg: unknown) => arg),
|
||||||
exists: vi.fn(() => true),
|
exists: vi.fn((arg: unknown) => arg),
|
||||||
or: vi.fn((..._args: unknown[]) => ({})),
|
sql: Object.assign(
|
||||||
sql: sqlMock,
|
() => ({ _op: "sql" }),
|
||||||
|
{ [Symbol.toPrimitive]: () => "sql" }
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../lib/s3.js", () => ({
|
vi.mock("../lib/s3.js", () => ({
|
||||||
getPresignedUploadUrl: vi.fn(),
|
getPresignedUploadUrl: vi.fn().mockResolvedValue("https://example.com/put"),
|
||||||
getPresignedGetUrl: vi.fn(),
|
getPresignedGetUrl: vi.fn().mockResolvedValue("https://example.com/get"),
|
||||||
deleteObject: vi.fn(),
|
deleteObject: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ─── 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) {
|
if (staffRow) c.set("staff", 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(() => {
|
||||||
resetMock();
|
resetDb();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── GRO-2014 error-handling suite ──────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("GET /:id/profile-summary — GRO-2014 error handling", () => {
|
describe("GET /pets/: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");
|
||||||
@@ -339,7 +237,7 @@ describe("GET /: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 () => {
|
||||||
enqueue("pets", []);
|
dbState.petRow = null;
|
||||||
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);
|
||||||
@@ -348,222 +246,40 @@ describe("GET /: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 () => {
|
||||||
enqueue("pets", petsTable);
|
dbState.linkageRow = null;
|
||||||
enqueue("appointments", []); // linkage check returns empty → 403
|
|
||||||
const app = buildApp(GROOMER);
|
const app = buildApp(GROOMER);
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
const res = await app.request(`/pets/${PET_UUID}/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_ID}/profile-summary`);
|
const res = await app.request(`/pets/${PET_UUID}/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_ID);
|
expect(body.id).toBe(PET_UUID);
|
||||||
expect(body.name).toBe("Biscuit");
|
expect(body.name).toBe("Biscuit");
|
||||||
expect(body.visitCount).toBe(1);
|
expect(body.visitCount).toBe(0);
|
||||||
expect(body.upcomingAppointment).toBeNull();
|
expect(body.upcomingAppointment).toBeNull();
|
||||||
expect(body.recentGroomingHistory).toBeInstanceOf(Array);
|
expect(body.recentGroomingHistory).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 200 with summary for a groomer with appointment linkage", async () => {
|
it("returns 200 with summary for a groomer with 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_ID}/profile-summary`);
|
const res = await app.request(`/pets/${PET_UUID}/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_ID);
|
expect(body.id).toBe(PET_UUID);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
enqueueThrow("pets", "simulated postgres uuid cast failure");
|
dbState.throwOnPetSelect = true;
|
||||||
const app = buildApp(MANAGER);
|
const app = buildApp(MANAGER);
|
||||||
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
const res = await app.request(`/pets/${PET_UUID}/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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+1
-41
@@ -7,7 +7,6 @@ import {
|
|||||||
eq,
|
eq,
|
||||||
exists,
|
exists,
|
||||||
getDb,
|
getDb,
|
||||||
impersonationSessions,
|
|
||||||
or,
|
or,
|
||||||
pets,
|
pets,
|
||||||
appointments,
|
appointments,
|
||||||
@@ -127,35 +126,6 @@ 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");
|
||||||
@@ -182,18 +152,8 @@ 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);
|
||||||
|
|
||||||
// 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) {
|
|
||||||
const ownerClientId = await resolveImpersonationClientId(db, c);
|
|
||||||
isOwner = !!ownerClientId && ownerClientId === pet.clientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Groomer RBAC: check appointment linkage to this pet's client
|
// Groomer RBAC: check appointment linkage to this pet's client
|
||||||
if (isGroomer && !isOwner) {
|
if (isGroomer) {
|
||||||
const [linkage] = await db
|
const [linkage] = await db
|
||||||
.select({ id: appointments.id })
|
.select({ id: appointments.id })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
|
|||||||
Reference in New Issue
Block a user