Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.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.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-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.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"}` |
|
||||||
|
|||||||
@@ -18,10 +18,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project .",
|
"build": "tsc --project .",
|
||||||
"generate": "drizzle-kit generate",
|
"generate": "drizzle-kit generate",
|
||||||
"wait-for-db": "node ./scripts/wait-for-db.mjs",
|
"migrate": "drizzle-kit migrate",
|
||||||
"migrate": "node ./scripts/wait-for-db.mjs && drizzle-kit migrate",
|
"seed": "tsx src/seed.ts",
|
||||||
"seed": "node ./scripts/wait-for-db.mjs && tsx src/seed.ts",
|
"reset": "tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
|
||||||
"reset": "node ./scripts/wait-for-db.mjs && tsx src/reset.ts && drizzle-kit migrate && tsx src/seed.ts",
|
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// wait-for-db.mjs
|
|
||||||
//
|
|
||||||
// GRO-2163: wait for / retry DNS resolution of the database hostname derived
|
|
||||||
// from DATABASE_URL before invoking `drizzle-kit migrate`. The first attempt
|
|
||||||
// of a fresh migrate-schema pod occasionally hits a transient CoreDNS miss
|
|
||||||
// (EAI_AGAIN) on `groombook-postgres-rw.<ns>.svc`; with backoffLimit: 2 the
|
|
||||||
// retry pod usually wins, but three unlucky attempts in a row trips
|
|
||||||
// BackoffLimitExceeded. Resolving once here, with backoff, removes the dice
|
|
||||||
// roll at the source so the first attempt reliably succeeds.
|
|
||||||
//
|
|
||||||
// Mirrors the belt-and-braces pattern used in GRO-1985 (no Corepack
|
|
||||||
// download fallback): we don't try to outsmart CoreDNS, we just don't ask
|
|
||||||
// drizzle-kit to do the very first DNS lookup of a freshly-scheduled pod.
|
|
||||||
//
|
|
||||||
// Configuration (env):
|
|
||||||
// WAIT_FOR_DB_MAX_ATTEMPTS default 12 (~30s of total wait at default backoff)
|
|
||||||
// WAIT_FOR_DB_BASE_DELAY_MS default 500
|
|
||||||
// WAIT_FOR_DB_MAX_DELAY_MS default 5000
|
|
||||||
// WAIT_FOR_DB_SKIP default unset; set to "1" to skip (debug only)
|
|
||||||
//
|
|
||||||
// On success: exit 0. On exhaustion: exit 1 so the Job's backoff is
|
|
||||||
// preserved (we don't want to silently mask a real outage by giving up
|
|
||||||
// after 30s and letting drizzle-kit fail with a less-actionable error).
|
|
||||||
|
|
||||||
import { setTimeout as delay } from "node:timers/promises";
|
|
||||||
import dns from "node:dns/promises";
|
|
||||||
|
|
||||||
const MAX_ATTEMPTS = Number(process.env.WAIT_FOR_DB_MAX_ATTEMPTS ?? 12);
|
|
||||||
const BASE_DELAY_MS = Number(process.env.WAIT_FOR_DB_BASE_DELAY_MS ?? 500);
|
|
||||||
const MAX_DELAY_MS = Number(process.env.WAIT_FOR_DB_MAX_DELAY_MS ?? 5000);
|
|
||||||
|
|
||||||
function parseHost(databaseUrl) {
|
|
||||||
try {
|
|
||||||
return new URL(databaseUrl).hostname || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveOnce(host) {
|
|
||||||
const start = Date.now();
|
|
||||||
const result = await dns.lookup(host);
|
|
||||||
return { address: result.address, ms: Date.now() - start };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (process.env.WAIT_FOR_DB_SKIP === "1") {
|
|
||||||
console.log("[wait-for-db] WAIT_FOR_DB_SKIP=1, skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
|
||||||
if (!databaseUrl) {
|
|
||||||
// Don't gate the migrate on a misconfigured env — let drizzle-kit fail
|
|
||||||
// loudly with its own clear error.
|
|
||||||
console.warn("[wait-for-db] DATABASE_URL not set; skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const host = parseHost(databaseUrl);
|
|
||||||
if (!host) {
|
|
||||||
console.warn(`[wait-for-db] could not parse hostname from DATABASE_URL; skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`[wait-for-db] host=${host} max_attempts=${MAX_ATTEMPTS} ` +
|
|
||||||
`base_delay_ms=${BASE_DELAY_MS} max_delay_ms=${MAX_DELAY_MS}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
||||||
try {
|
|
||||||
const { address, ms } = await resolveOnce(host);
|
|
||||||
console.log(`[wait-for-db] ok attempt=${attempt} host=${host} -> ${address} (${ms}ms)`);
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
const code = err?.code ?? "UNKNOWN";
|
|
||||||
const transient = code === "EAI_AGAIN" || code === "ENOTFOUND" || code === "EAI_NODATA";
|
|
||||||
if (!transient) {
|
|
||||||
// Hard error (e.g. invalid hostname): surface and let drizzle-kit fail
|
|
||||||
// with a real error rather than spinning.
|
|
||||||
console.error(`[wait-for-db] non-transient DNS error attempt=${attempt} code=${code}: ${err.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (attempt === MAX_ATTEMPTS) {
|
|
||||||
console.error(
|
|
||||||
`[wait-for-db] exhausted attempts=${MAX_ATTEMPTS} host=${host} last_code=${code}; exiting 1`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const backoff = Math.min(
|
|
||||||
MAX_DELAY_MS,
|
|
||||||
BASE_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * BASE_DELAY_MS),
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[wait-for-db] transient attempt=${attempt} code=${code} retry_in_ms=${backoff}`,
|
|
||||||
);
|
|
||||||
await delay(backoff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(`[wait-for-db] fatal: ${err?.message ?? err}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -177,10 +177,7 @@ describe("PATCH /portal/pets/:petId", () => {
|
|||||||
expect(persisted.weightKg).toBe("18.25");
|
expect(persisted.weightKg).toBe("18.25");
|
||||||
expect(persisted.groomingNotes).toBe("old grooming notes");
|
expect(persisted.groomingNotes).toBe("old grooming notes");
|
||||||
expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo");
|
expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo");
|
||||||
// photoKey is NOT writable via portal PATCH (GRO-2187 S3 key-hijack fix):
|
expect(persisted.photoKey).toBe("pets/rex.jpg");
|
||||||
// the web form round-trips the GET-shaped photoUrl, but the server must not
|
|
||||||
// persist it. Photo changes go through the key-validated upload flow.
|
|
||||||
expect(persisted.photoKey).toBeUndefined();
|
|
||||||
expect(persisted.coatType).toBe("double");
|
expect(persisted.coatType).toBe("double");
|
||||||
expect(persisted.petSizeCategory).toBe("extra_large");
|
expect(persisted.petSizeCategory).toBe("extra_large");
|
||||||
expect(persisted.preferredCuts).toEqual(["teddy bear", "puppy cut"]);
|
expect(persisted.preferredCuts).toEqual(["teddy bear", "puppy cut"]);
|
||||||
@@ -190,55 +187,6 @@ describe("PATCH /portal/pets/:petId", () => {
|
|||||||
expect(persisted.updatedAt).toBeInstanceOf(Date);
|
expect(persisted.updatedAt).toBeInstanceOf(Date);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GRO-2187 security regression: a portal customer must not be able to set the
|
|
||||||
// S3 object key. photoKey is consumed server-side by getPresignedGetUrl /
|
|
||||||
// deleteObject; the upload path guards keys with a pets/{petId}/ prefix, and the
|
|
||||||
// portal PATCH must not offer a bypass. A foreign/arbitrary photoUrl is accepted
|
|
||||||
// (Zod strips the unknown key) but must leave photoKey untouched.
|
|
||||||
it("does not mutate photoKey when a foreign photoUrl is supplied (200)", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
const ownKey = `pets/${PET_ID}/original.jpg`;
|
|
||||||
selectPetRow = { ...PET, photoKey: ownKey };
|
|
||||||
|
|
||||||
const res = await jsonPatch(
|
|
||||||
`/portal/pets/${PET_ID}`,
|
|
||||||
{
|
|
||||||
name: "Rex",
|
|
||||||
// attacker-chosen key pointing at another tenant's object
|
|
||||||
photoUrl: "pets/00000000-0000-0000-0000-0000000000ff/victim-secret.jpg",
|
|
||||||
},
|
|
||||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const persisted = updatedValues[0]!;
|
|
||||||
// The attacker-supplied key never reaches the update payload.
|
|
||||||
expect(persisted.photoKey).toBeUndefined();
|
|
||||||
// And the stored key is unchanged from the pet's own value.
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.photoUrl).toBe(ownKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The length/array caps live in the Zod schema, so violations are rejected by
|
|
||||||
// zValidator with 400 (in-handler enum checks are what return 422).
|
|
||||||
it("returns 400 when a medicalAlert description exceeds the length cap", async () => {
|
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
|
||||||
selectPetRow = PET;
|
|
||||||
|
|
||||||
const res = await jsonPatch(
|
|
||||||
`/portal/pets/${PET_ID}`,
|
|
||||||
{
|
|
||||||
medicalAlerts: [
|
|
||||||
{ type: "allergy", description: "x".repeat(2001), severity: "low" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ "X-Impersonation-Session-Id": SESSION_ID }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(updatedValues).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the weight key when weightKg is absent", async () => {
|
it("falls back to the weight key when weightKg is absent", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
selectPetRow = PET;
|
selectPetRow = PET;
|
||||||
|
|||||||
+6
-11
@@ -261,8 +261,8 @@ const PORTAL_PET_SIZE_ALIASES: Record<string, string> = { xlarge: "extra_large"
|
|||||||
|
|
||||||
const portalMedicalAlertSchema = z.object({
|
const portalMedicalAlertSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
type: z.string().max(2000),
|
type: z.string(),
|
||||||
description: z.string().max(2000),
|
description: z.string(),
|
||||||
severity: z.enum(["low", "medium", "high"]),
|
severity: z.enum(["low", "medium", "high"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -275,16 +275,12 @@ const portalPetUpdateSchema = z.object({
|
|||||||
birthDate: z.string().nullable().optional(),
|
birthDate: z.string().nullable().optional(),
|
||||||
notes: z.string().max(2000).nullable().optional(),
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
healthAlerts: z.string().max(2000).nullable().optional(),
|
healthAlerts: z.string().max(2000).nullable().optional(),
|
||||||
// photoUrl/photoKey are intentionally NOT writable here: photoKey is a trusted
|
photoUrl: z.string().nullable().optional(),
|
||||||
// S3 object key consumed server-side (getPresignedGetUrl / deleteObject), and the
|
|
||||||
// upload path (pets.ts) already enforces a pets/{petId}/ prefix guard against key
|
|
||||||
// hijacking. Photo changes go through the dedicated upload + /photo/confirm flow.
|
|
||||||
// The web form round-trips the GET-shaped photoUrl; Zod strips it as an unknown key.
|
|
||||||
// coatType / petSizeCategory validated in-handler so bad values return 422.
|
// coatType / petSizeCategory validated in-handler so bad values return 422.
|
||||||
coatType: z.string().nullable().optional(),
|
coatType: z.string().nullable().optional(),
|
||||||
petSizeCategory: z.string().nullable().optional(),
|
petSizeCategory: z.string().nullable().optional(),
|
||||||
preferredCuts: z.array(z.string().max(2000)).max(50).nullable().optional(),
|
preferredCuts: z.array(z.string()).nullable().optional(),
|
||||||
medicalAlerts: z.array(portalMedicalAlertSchema).max(50).nullable().optional(),
|
medicalAlerts: z.array(portalMedicalAlertSchema).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.patch(
|
portalRouter.patch(
|
||||||
@@ -326,8 +322,7 @@ portalRouter.patch(
|
|||||||
|
|
||||||
if (body.notes !== undefined) updateData.groomingNotes = body.notes;
|
if (body.notes !== undefined) updateData.groomingNotes = body.notes;
|
||||||
if (body.healthAlerts !== undefined) updateData.healthAlerts = body.healthAlerts;
|
if (body.healthAlerts !== undefined) updateData.healthAlerts = body.healthAlerts;
|
||||||
// photoKey is intentionally not writable here — see portalPetUpdateSchema note.
|
if (body.photoUrl !== undefined) updateData.photoKey = body.photoUrl;
|
||||||
// Photo changes go through the key-validated upload + /photo/confirm flow.
|
|
||||||
|
|
||||||
if (body.coatType !== undefined) {
|
if (body.coatType !== undefined) {
|
||||||
if (body.coatType !== null && !PORTAL_COAT_TYPES.includes(body.coatType)) {
|
if (body.coatType !== null && !PORTAL_COAT_TYPES.includes(body.coatType)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user