Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
452 changes: 451 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
218 changes: 218 additions & 0 deletions src/__tests__/zod-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof validateBody>[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();
});
});
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
87 changes: 87 additions & 0 deletions src/middleware/schemas.ts
Original file line number Diff line number Diff line change
@@ -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: <Resource><Action>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<typeof CreateSlotBodySchema>;

// ─── Booking Intents ──────────────────────────────────────────────────────────

/**
* Schema for POST /api/v1/booking-intents body.
*
* Fields:
* - slotId string matching slot-<uuid> 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<typeof CreateBookingIntentBodySchema>;
52 changes: 52 additions & 0 deletions src/middleware/validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { ZodSchema, ZodError } from "zod";
import {
BadRequestError,
InternalServerError,
Expand Down Expand Up @@ -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<T>(schema: ZodSchema<T>) {
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);
}
};
}
Loading
Loading