Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76d9850464 | |||
| 96dbb8c41d | |||
| 636fa713e1 | |||
| 6120b96c7c | |||
| eb92f99c4a | |||
| 587fd4ec95 | |||
| 8cf72d926d | |||
| 8721f0b63c | |||
| 027e012a58 | |||
| b3db206588 | |||
| 6538406db2 | |||
| e2eacbc9fe | |||
| e639cc82d1 | |||
| f2931d7be2 | |||
| d4a4ddce37 | |||
| bd384bdf5c | |||
| 411c42b2c4 | |||
| bf97849324 | |||
| 7181d41b24 | |||
| 4e9c4c5e08 | |||
| 16c959434b | |||
| 23484dc90a | |||
| 6a81a52a50 | |||
| 5a4b9a98bd | |||
| f7f88156e1 | |||
| 8af5a49d14 |
@@ -165,6 +165,8 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
|
||||
| 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.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) |
|
||||
| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) |
|
||||
| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note |
|
||||
| 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.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"}` |
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
|
||||
// GRO-2235: a duplicate active waitlist entry violates the partial unique index
|
||||
// idx_waitlist_active_unique. postgres-js surfaces it as SQLSTATE 23505 — the
|
||||
// handler must return a friendly 409, not a generic 500. The first insert still
|
||||
// returns 201, and unrelated errors still surface as 500.
|
||||
|
||||
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
|
||||
const PET_ID = "880e8400-e29b-41d4-a716-446655440004";
|
||||
const SERVICE_ID = "990e8400-e29b-41d4-a716-446655440005";
|
||||
|
||||
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
|
||||
|
||||
const ACTIVE_SESSION = {
|
||||
id: SESSION_ID,
|
||||
clientId: CLIENT_ID,
|
||||
status: "active" as const,
|
||||
reason: "manual",
|
||||
startedAt: new Date(),
|
||||
expiresAt: futureDate(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Behaviour knob for the waitlist insert: "ok" returns a row, "duplicate" throws
|
||||
// a postgres-js-shaped unique-violation, "other" throws an unrelated error.
|
||||
let waitlistInsertMode: "ok" | "duplicate" | "other" = "ok";
|
||||
|
||||
function resetMock() {
|
||||
waitlistInsertMode = "ok";
|
||||
}
|
||||
|
||||
function tableProxy(name: string) {
|
||||
return new Proxy(
|
||||
{ _name: name },
|
||||
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("@groombook/db", () => {
|
||||
function makeChainable(data: unknown[]): unknown {
|
||||
const arr = [...data];
|
||||
const chain = new Proxy(arr, {
|
||||
get(target, prop) {
|
||||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||||
return () => chain;
|
||||
}
|
||||
// @ts-expect-error proxy
|
||||
return target[prop];
|
||||
},
|
||||
});
|
||||
return chain;
|
||||
}
|
||||
|
||||
const impersonationSessions = tableProxy("impersonationSessions");
|
||||
const waitlistEntries = tableProxy("waitlistEntries");
|
||||
const impersonationAuditLogs = tableProxy("impersonationAuditLogs");
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: (table: { _name: string }) => {
|
||||
if (table._name === "impersonationSessions") {
|
||||
return makeChainable([ACTIVE_SESSION]);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
insert: (table: { _name: string }) => ({
|
||||
values: (vals: Record<string, unknown>) => ({
|
||||
returning: () => {
|
||||
if (table._name === "waitlistEntries") {
|
||||
if (waitlistInsertMode === "duplicate") {
|
||||
throw Object.assign(new Error("duplicate key value"), { code: "23505" });
|
||||
}
|
||||
if (waitlistInsertMode === "other") {
|
||||
throw Object.assign(new Error("not null violation"), { code: "23502" });
|
||||
}
|
||||
return [{ id: "entry-1", ...vals }];
|
||||
}
|
||||
// impersonationAuditLogs and anything else: succeed silently.
|
||||
return [{ id: "audit-1", ...vals }];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: () => ({ where: () => Promise.resolve() }),
|
||||
}),
|
||||
}),
|
||||
impersonationSessions,
|
||||
waitlistEntries,
|
||||
impersonationAuditLogs,
|
||||
appointments: tableProxy("appointments"),
|
||||
clients: tableProxy("clients"),
|
||||
pets: tableProxy("pets"),
|
||||
services: tableProxy("services"),
|
||||
staff: tableProxy("staff"),
|
||||
invoices: tableProxy("invoices"),
|
||||
invoiceLineItems: tableProxy("invoiceLineItems"),
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { portalRouter } = await import("../routes/portal.js");
|
||||
|
||||
const app = new Hono();
|
||||
app.route("/portal", portalRouter);
|
||||
|
||||
function postWaitlist(body: unknown) {
|
||||
return app.request("/portal/waitlist", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Impersonation-Session-Id": SESSION_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
petId: PET_ID,
|
||||
serviceId: SERVICE_ID,
|
||||
preferredDate: "2026-07-01",
|
||||
preferredTime: "09:00",
|
||||
};
|
||||
|
||||
beforeEach(() => resetMock());
|
||||
|
||||
describe("POST /portal/waitlist duplicate handling (GRO-2235)", () => {
|
||||
it("returns 201 for the first insert", async () => {
|
||||
waitlistInsertMode = "ok";
|
||||
const res = await postWaitlist(VALID_BODY);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("returns 409 with a friendly message for a duplicate (23505)", async () => {
|
||||
waitlistInsertMode = "duplicate";
|
||||
const res = await postWaitlist(VALID_BODY);
|
||||
expect(res.status).toBe(409);
|
||||
const json = (await res.json()) as { error: string };
|
||||
expect(json.error).toBe(
|
||||
"You already have a booking for this pet at that date and time."
|
||||
);
|
||||
});
|
||||
|
||||
it("still surfaces unrelated DB errors as 500", async () => {
|
||||
waitlistInsertMode = "other";
|
||||
const res = await postWaitlist(VALID_BODY);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
+10
-26
@@ -596,32 +596,16 @@ portalRouter.post(
|
||||
const body = c.req.valid("json");
|
||||
const clientId = c.get("portalClientId");
|
||||
|
||||
let entry;
|
||||
try {
|
||||
[entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
.values({
|
||||
clientId,
|
||||
petId: body.petId,
|
||||
serviceId: body.serviceId,
|
||||
preferredDate: body.preferredDate,
|
||||
preferredTime: normalizeTime(body.preferredTime),
|
||||
})
|
||||
.returning();
|
||||
} catch (err) {
|
||||
// An exact duplicate active waitlist entry violates the partial unique
|
||||
// index idx_waitlist_active_unique (client_id, pet_id, service_id,
|
||||
// preferred_date, preferred_time WHERE status='active'). postgres-js
|
||||
// surfaces this as SQLSTATE 23505 — return a friendly 409 rather than a
|
||||
// generic 500 (GRO-2235). Unrelated errors still surface as 500.
|
||||
if ((err as { code?: string })?.code === "23505") {
|
||||
return c.json(
|
||||
{ error: "You already have a booking for this pet at that date and time." },
|
||||
409
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
.values({
|
||||
clientId,
|
||||
petId: body.petId,
|
||||
serviceId: body.serviceId,
|
||||
preferredDate: body.preferredDate,
|
||||
preferredTime: normalizeTime(body.preferredTime),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(entry, 201);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user