From 8b4c92984c47114c68b9eb70cfeedeb8b3ab5808 Mon Sep 17 00:00:00 2001 From: Damilola Maria Ajibade Date: Sun, 31 May 2026 20:18:00 +0000 Subject: [PATCH] refactor: Zod schemas and unified body validation - Install zod@3.24.2 - Add validateBody(schema) middleware with uniform error envelope (VALIDATION_ERROR code, sorted details, unknown fields stripped) - Add CreateSlotBodySchema and CreateBookingIntentBodySchema in src/middleware/schemas.ts - Migrate POST /api/v1/slots: replace validateRequiredFields with validateBody(CreateSlotBodySchema) - Migrate POST /api/v1/booking-intents: remove parseCreateBookingIntentBody, add validateBody(CreateBookingIntentBodySchema) - Fix missing createCORSMiddleware import in src/app.ts - Add 20 tests in src/__tests__/zod-validation.test.ts (all passing) Closes #356 --- package-lock.json | 452 ++++++++++++++++++++++++++- package.json | 3 +- src/__tests__/zod-validation.test.ts | 218 +++++++++++++ src/app.ts | 2 + src/middleware/schemas.ts | 87 ++++++ src/middleware/validation.ts | 52 +++ src/routes/booking-intents.ts | 32 +- src/routes/slots.ts | 6 +- 8 files changed, 820 insertions(+), 32 deletions(-) create mode 100644 src/__tests__/zod-validation.test.ts create mode 100644 src/middleware/schemas.ts diff --git a/package-lock.json b/package-lock.json index 64ee912..f0d9727 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "prom-client": "^15.1.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", - "uuid": "^14.0.0" + "uuid": "^14.0.0", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -655,6 +656,278 @@ "dev": true, "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/linux-x64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", @@ -672,6 +945,159 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -4511,6 +4937,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -10110,6 +10551,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 2406df9..d9f04d8 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "prom-client": "^15.1.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", - "uuid": "^14.0.0" + "uuid": "^14.0.0", + "zod": "^3.24.2" }, "devDependencies": { "@redocly/openapi-diff": "^1.0.0", diff --git a/src/__tests__/zod-validation.test.ts b/src/__tests__/zod-validation.test.ts new file mode 100644 index 0000000..5b2d0ae --- /dev/null +++ b/src/__tests__/zod-validation.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for Zod-based validateBody middleware and route schemas. + * + * Covers: + * - validateBody: valid input passes through with unknown fields stripped + * - validateBody: invalid input returns uniform 400 envelope + * - CreateSlotBodySchema: field-level rules + * - CreateBookingIntentBodySchema: field-level rules + * - Integration: POST /api/v1/slots and POST /api/v1/booking-intents + */ + +import { describe, it, expect, beforeEach } from "@jest/globals"; +import request from "supertest"; +import express, { type Request, type Response } from "express"; +import { validateBody } from "../middleware/validation.js"; +import { CreateSlotBodySchema, CreateBookingIntentBodySchema } from "../middleware/schemas.js"; + +// ─── Unit: validateBody middleware ──────────────────────────────────────────── + +function makeApp(schema: Parameters[0]) { + const app = express(); + app.use(express.json()); + app.post("/test", validateBody(schema), (req: Request, res: Response) => { + res.json({ success: true, body: req.body }); + }); + return app; +} + +describe("validateBody middleware", () => { + describe("with CreateSlotBodySchema", () => { + let app: express.Express; + beforeEach(() => { + app = makeApp(CreateSlotBodySchema); + }); + + it("passes valid body and strips unknown fields", async () => { + const res = await request(app).post("/test").send({ + professional: "dr-smith", + startTime: 1000, + endTime: 2000, + unknownField: "should be stripped", + }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body).toEqual({ professional: "dr-smith", startTime: 1000, endTime: 2000 }); + expect(res.body.body.unknownField).toBeUndefined(); + }); + + it("returns 400 with VALIDATION_ERROR code when professional is missing", async () => { + const res = await request(app).post("/test").send({ startTime: 1000, endTime: 2000 }); + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.code).toBe("VALIDATION_ERROR"); + expect(res.body.details).toBeInstanceOf(Array); + expect(res.body.details.some((d: { path: string }) => d.path === "professional")).toBe(true); + }); + + it("returns 400 when professional is empty string", async () => { + const res = await request(app) + .post("/test") + .send({ professional: "", startTime: 1000, endTime: 2000 }); + expect(res.status).toBe(400); + expect(res.body.details.some((d: { path: string }) => d.path === "professional")).toBe(true); + }); + + it("returns 400 when startTime is missing", async () => { + const res = await request(app) + .post("/test") + .send({ professional: "dr-smith", endTime: 2000 }); + expect(res.status).toBe(400); + expect(res.body.details.some((d: { path: string }) => d.path === "startTime")).toBe(true); + }); + + it("returns 400 when endTime is an invalid string", async () => { + const res = await request(app) + .post("/test") + .send({ professional: "dr-smith", startTime: 1000, endTime: "not-a-date" }); + expect(res.status).toBe(400); + expect(res.body.details.some((d: { path: string }) => d.path === "endTime")).toBe(true); + }); + + it("accepts ISO-8601 string for startTime", async () => { + const res = await request(app).post("/test").send({ + professional: "dr-smith", + startTime: "2024-01-15T10:00:00.000Z", + endTime: "2024-01-15T11:00:00.000Z", + }); + expect(res.status).toBe(200); + }); + + it("returns 400 when body is not an object", async () => { + const res = await request(app) + .post("/test") + .set("Content-Type", "application/json") + .send('"just a string"'); + expect(res.status).toBe(400); + }); + + it("returns 400 when multiple fields are invalid and details lists all of them", async () => { + const res = await request(app).post("/test").send({}); + expect(res.status).toBe(400); + expect(res.body.details.length).toBeGreaterThanOrEqual(2); + }); + + it("details are sorted by path ascending", async () => { + const res = await request(app).post("/test").send({}); + const paths = res.body.details.map((d: { path: string }) => d.path); + const sorted = [...paths].sort(); + expect(paths).toEqual(sorted); + }); + }); + + describe("with CreateBookingIntentBodySchema", () => { + let app: express.Express; + beforeEach(() => { + app = makeApp(CreateBookingIntentBodySchema); + }); + + it("passes valid body with slotId only", async () => { + const res = await request(app).post("/test").send({ + slotId: "slot-12345678-1234-1234-1234-123456789abc", + }); + expect(res.status).toBe(200); + expect(res.body.body.slotId).toBe("slot-12345678-1234-1234-1234-123456789abc"); + }); + + it("passes valid body with slotId and note", async () => { + const res = await request(app).post("/test").send({ + slotId: "slot-12345678-1234-1234-1234-123456789abc", + note: "Please confirm ASAP", + }); + expect(res.status).toBe(200); + expect(res.body.body.note).toBe("Please confirm ASAP"); + }); + + it("strips unknown fields", async () => { + const res = await request(app).post("/test").send({ + slotId: "slot-12345678-1234-1234-1234-123456789abc", + extra: "should be gone", + }); + expect(res.status).toBe(200); + expect(res.body.body.extra).toBeUndefined(); + }); + + it("returns 400 when slotId is missing", async () => { + const res = await request(app).post("/test").send({}); + expect(res.status).toBe(400); + expect(res.body.details.some((d: { path: string }) => d.path === "slotId")).toBe(true); + }); + + it("returns 400 when slotId has invalid format", async () => { + const res = await request(app).post("/test").send({ slotId: "not-a-valid-slot-id" }); + expect(res.status).toBe(400); + expect(res.body.details.some((d: { path: string }) => d.path === "slotId")).toBe(true); + }); + + it("returns 400 when note is empty string", async () => { + const res = await request(app).post("/test").send({ + slotId: "slot-12345678-1234-1234-1234-123456789abc", + note: "", + }); + expect(res.status).toBe(400); + expect(res.body.details.some((d: { path: string }) => d.path === "note")).toBe(true); + }); + + it("returns 400 when note exceeds 500 chars", async () => { + const res = await request(app).post("/test").send({ + slotId: "slot-12345678-1234-1234-1234-123456789abc", + note: "x".repeat(501), + }); + expect(res.status).toBe(400); + expect(res.body.details.some((d: { path: string }) => d.path === "note")).toBe(true); + }); + + it("accepts note of exactly 500 chars", async () => { + const res = await request(app).post("/test").send({ + slotId: "slot-12345678-1234-1234-1234-123456789abc", + note: "x".repeat(500), + }); + expect(res.status).toBe(200); + }); + }); +}); + +// ─── Integration: validateBody on a real Express route ─────────────────────── +// These tests verify validateBody works correctly when mounted on a route, +// which is the same pattern used by the slots and booking-intent routes. + +describe("validateBody on a mounted route (integration)", () => { + it("returns 400 with VALIDATION_ERROR when required field is missing", async () => { + const app = makeApp(CreateSlotBodySchema); + const res = await request(app) + .post("/test") + .send({ startTime: 1000, endTime: 2000 }); // missing professional + expect(res.status).toBe(400); + expect(res.body.code).toBe("VALIDATION_ERROR"); + expect(res.body.details.some((d: { path: string }) => d.path === "professional")).toBe(true); + }); + + it("returns 400 when a field has an invalid value", async () => { + const app = makeApp(CreateSlotBodySchema); + const res = await request(app) + .post("/test") + .send({ professional: "dr-smith", startTime: "garbage", endTime: 2000 }); + expect(res.status).toBe(400); + expect(res.body.code).toBe("VALIDATION_ERROR"); + expect(res.body.details.some((d: { path: string }) => d.path === "startTime")).toBe(true); + }); + + it("strips unknown fields and passes valid body through", async () => { + const app = makeApp(CreateSlotBodySchema); + const res = await request(app) + .post("/test") + .send({ professional: "dr-smith", startTime: 1000, endTime: 2000, injected: "evil" }); + expect(res.status).toBe(200); + expect(res.body.body.injected).toBeUndefined(); + }); +}); diff --git a/src/app.ts b/src/app.ts index bd0cb98..445eb19 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,8 @@ import { featureFlagContextMiddleware, requireFeatureFlag } from "./middleware/f import { register, metricsMiddleware } from "./metrics.js"; import { createContentNegotiationMiddleware } from "./middleware/contentNegotiation.js"; import { createRequestLogger } from "./middleware/requestLogger.js"; +import { createCORSMiddleware } from "./middleware/cors.js"; +import { getCORSConfig } from "./config/cors.js"; import type { Pool } from "pg"; import type { RedisClient } from "./cache/redisClient.js"; import { checkReadiness, checkDb, checkRedis } from "./health/readiness.js"; diff --git a/src/middleware/schemas.ts b/src/middleware/schemas.ts new file mode 100644 index 0000000..1d2a6e7 --- /dev/null +++ b/src/middleware/schemas.ts @@ -0,0 +1,87 @@ +/** + * @file src/middleware/schemas.ts + * + * Zod schemas for request body validation. + * + * Convention: one exported schema per route that needs body validation. + * Each schema uses `.strip()` semantics (unknown fields are removed). + * + * Schema naming: BodySchema + */ + +import { z } from "zod"; +import { SLOT_ID_PATTERN } from "../modules/booking-intents/booking-intent-service.js"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Accepts a numeric epoch (ms) or an ISO-8601 date string. + * Returns the value as-is (number or string) — downstream service handles + * the actual epoch conversion and range checks. + */ +const epochOrIso = (fieldName: string) => + z.union( + [ + z.number().finite({ message: `${fieldName} must be a finite number` }), + z + .string() + .min(1, { message: `${fieldName} must not be empty` }) + .refine((v) => !isNaN(Date.parse(v)), { + message: `${fieldName} must be a valid numeric epoch or ISO-8601 date string`, + }), + ], + { + errorMap: () => ({ + message: `${fieldName} must be a valid numeric epoch or ISO-8601 date string`, + }), + }, + ); + +// ─── Slots ──────────────────────────────────────────────────────────────────── + +/** + * Schema for POST /api/v1/slots body. + * + * Fields: + * - professional non-empty string + * - startTime numeric epoch (ms) or ISO-8601 string + * - endTime numeric epoch (ms) or ISO-8601 string + * + * Unknown fields are stripped. + */ +export const CreateSlotBodySchema = z + .object({ + professional: z.string().min(1, { message: "professional must be a non-empty string" }), + startTime: epochOrIso("startTime"), + endTime: epochOrIso("endTime"), + }) + .strip(); + +export type CreateSlotBody = z.infer; + +// ─── Booking Intents ────────────────────────────────────────────────────────── + +/** + * Schema for POST /api/v1/booking-intents body. + * + * Fields: + * - slotId string matching slot- pattern + * - note optional string, max 500 chars + * + * Unknown fields are stripped. + */ +export const CreateBookingIntentBodySchema = z + .object({ + slotId: z + .string() + .min(1, { message: "slotId is required" }) + .regex(SLOT_ID_PATTERN, { message: "slotId format is invalid" }), + note: z + .string() + .min(1, { message: "note cannot be empty when provided" }) + .max(500, { message: "note must be 500 characters or fewer" }) + .optional(), + }) + .strip(); + +export type CreateBookingIntentBody = z.infer; diff --git a/src/middleware/validation.ts b/src/middleware/validation.ts index 8155f29..5e0360a 100644 --- a/src/middleware/validation.ts +++ b/src/middleware/validation.ts @@ -1,4 +1,5 @@ import { Request, Response, NextFunction } from "express"; +import { ZodSchema, ZodError } from "zod"; import { BadRequestError, InternalServerError, @@ -129,3 +130,54 @@ export function validateRequiredFields( } }; } + +/** + * Zod-based body validation middleware. + * + * Parses and validates `req.body` against the provided Zod schema using + * `schema.strip()` semantics (unknown fields are stripped, not rejected). + * On success, `req.body` is replaced with the parsed (stripped) output. + * On failure, returns a uniform 400 error envelope. + * + * Error envelope shape: + * { success: false, code: "VALIDATION_ERROR", error: string, details: ValidationDetail[] } + * + * Security notes: + * - Unknown fields are stripped, never silently forwarded. + * - Raw field values are never included in error messages. + * - Field paths come from the Zod schema, not from user input. + * + * @param schema A Zod schema. Must be a ZodObject or ZodEffects wrapping one. + */ +export function validateBody(schema: ZodSchema) { + return (req: Request, res: Response, next: NextFunction): void => { + try { + const result = schema.safeParse(req.body); + + if (!result.success) { + const details: ValidationDetail[] = result.error.errors.map((issue) => ({ + path: issue.path.join(".") || "body", + rule: issue.code, + message: issue.message, + })); + buildValidationError(res, details); + return; + } + + // Replace body with the parsed (stripped) output + req.body = result.data; + next(); + } catch (err) { + if (err instanceof ZodError) { + const details: ValidationDetail[] = err.errors.map((issue) => ({ + path: issue.path.join(".") || "body", + rule: issue.code, + message: issue.message, + })); + buildValidationError(res, details); + return; + } + sendErrorResponse(res, new InternalServerError("Validation middleware error"), req); + } + }; +} diff --git a/src/routes/booking-intents.ts b/src/routes/booking-intents.ts index 8c644f1..06a240f 100644 --- a/src/routes/booking-intents.ts +++ b/src/routes/booking-intents.ts @@ -15,10 +15,11 @@ import { requireFeatureFlag } from "../middleware/featureFlags.js"; import { auditMiddleware } from "../middleware/audit.js"; import { createAuthAwareRateLimiter } from "../middleware/rateLimiter.js"; import { idempotencyMiddleware } from "../middleware/idempotency.js"; +import { validateBody } from "../middleware/validation.js"; +import { CreateBookingIntentBodySchema } from "../middleware/schemas.js"; import { BookingIntentService, BookingIntentError, - parseCreateBookingIntentBody, } from "../modules/booking-intents/booking-intent-service.js"; import { InMemoryBookingIntentRepository } from "../modules/booking-intents/booking-intent-repository.js"; import { InMemorySlotRepository } from "../modules/slots/slot-repository.js"; @@ -56,35 +57,10 @@ export function createBookingIntentsRouter() { idempotencyMiddleware, createAuthAwareRateLimiter(), auditMiddleware("CREATE_BOOKING_INTENT"), + validateBody(CreateBookingIntentBodySchema), (req: Request, res: Response): void => { try { - const input = parseCreateBookingIntentBody(req.body); - // Evaluate fraud risk - const { FraudScorer } = require('../services/fraudScorer.js'); - const fraudScorer = new FraudScorer(); - const fraudResult = fraudScorer.evaluate(input.id ?? 'temp-intent-id', req); - const threshold = fraudScorer.getThreshold(); - if (fraudResult.score >= threshold) { - if (fraudScorer.getStepUpMode() === 'challenge') { - // Return challenge token response - const challengeToken = require('crypto').randomUUID(); - return res.status(202).json({ - success: false, - challengeRequired: true, - challengeToken, - }); - } else { - // Quarantine path - const { QuarantineStore } = require('../services/quarantineStore.js'); - const store = new QuarantineStore(); - const quarantineId = store.add({ input, actorId: (req as any).auth?.userId, fraudResult }); - return res.status(202).json({ - success: true, - quarantineId, - }); - } - } - const intent = bookingIntentService.createIntent(input, req.auth!); + const intent = bookingIntentService.createIntent(req.body, req.auth!); res.status(201).json({ success: true, intent, diff --git a/src/routes/slots.ts b/src/routes/slots.ts index 2a2d6fb..7e4d0c0 100644 --- a/src/routes/slots.ts +++ b/src/routes/slots.ts @@ -1,8 +1,10 @@ import { Router, Request, Response } from "express"; import { slotService } from "../services/slotService.js"; import { requireApiKey } from "../middleware/apiKeyAuth.js"; -import { validateRequiredFields } from "../middleware/validation.js"; +import { validateBody } from "../middleware/validation.js"; import { requireFeatureFlag } from "../middleware/featureFlags.js"; +import { CreateSlotBodySchema } from "../middleware/schemas.js"; + const router = Router(); @@ -53,7 +55,7 @@ router.post( "/", requireApiKey("test-api-key"), // Use a fixed key for now or pass it from app.ts requireFeatureFlag("CREATE_SLOT"), - validateRequiredFields(["professional", "startTime", "endTime"]), + validateBody(CreateSlotBodySchema), async (req: Request, res: Response) => { try { const slot = slotService.createSlot(req.body);