From f3557a4e25ac47630134bf5506be99b7842e3411 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Thu, 20 Aug 2026 11:29:35 +0000 Subject: [PATCH] fix(rest/nodejs): enforce integer and minimum bounds on line item quantity - In the published @ucp-js/sdk (0.4.3 / 0.4.4), LineItemCreateRequestSchema and LineItemUpdateRequestSchema generated quantity as an unconstrained number without integer or minimum value checks. - As a result, checkout creation and update requests with quantity <= 0 or floating-point numbers passed schema validation and produced negative checkout totals. - Extend the line item and checkout request schemas in rest/nodejs models to enforce quantity as an integer >= 1 (matching line_item.json and the Python reference implementation). - Add regression unit tests in validation_flow.test.ts covering 0, negative, and fractional quantities on checkout creation and updates. Closes #194 --- rest/nodejs/src/models/index.ts | 43 +++++++++ rest/nodejs/test/validation_flow.test.ts | 114 ++++++++++++++++++++++- 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/rest/nodejs/src/models/index.ts b/rest/nodejs/src/models/index.ts index cae5e0e..08f4531 100644 --- a/rest/nodejs/src/models/index.ts +++ b/rest/nodejs/src/models/index.ts @@ -12,4 +12,47 @@ // See the License for the specific language governing permissions and // limitations under the License. +import * as sdk from "@ucp-js/sdk"; +import { z } from "zod"; + export * from "@ucp-js/sdk"; + +export const LineItemCreateRequestSchema = + sdk.LineItemCreateRequestSchema.extend({ + quantity: z.number().int().gte(1), + }); +export type LineItemCreateRequest = z.infer; + +export const LineItemUpdateRequestSchema = + sdk.LineItemUpdateRequestSchema.extend({ + quantity: z.number().int().gte(1), + }); +export type LineItemUpdateRequest = z.infer; + +export const CheckoutCreateRequestSchema = + sdk.CheckoutCreateRequestSchema.extend({ + line_items: z.array(LineItemCreateRequestSchema), + }); +export type CheckoutCreateRequest = z.infer; + +export const CheckoutUpdateRequestSchema = + sdk.CheckoutUpdateRequestSchema.extend({ + line_items: z.array(LineItemUpdateRequestSchema), + }); +export type CheckoutUpdateRequest = z.infer; + +export const ExtendedCheckoutCreateRequestSchema = + sdk.ExtendedCheckoutCreateRequestSchema.extend({ + line_items: z.array(LineItemCreateRequestSchema), + }); +export type ExtendedCheckoutCreateRequest = z.infer< + typeof ExtendedCheckoutCreateRequestSchema +>; + +export const ExtendedCheckoutUpdateRequestSchema = + sdk.ExtendedCheckoutUpdateRequestSchema.extend({ + line_items: z.array(LineItemUpdateRequestSchema), + }); +export type ExtendedCheckoutUpdateRequest = z.infer< + typeof ExtendedCheckoutUpdateRequestSchema +>; diff --git a/rest/nodejs/test/validation_flow.test.ts b/rest/nodejs/test/validation_flow.test.ts index f7cb86c..a251964 100644 --- a/rest/nodejs/test/validation_flow.test.ts +++ b/rest/nodejs/test/validation_flow.test.ts @@ -20,8 +20,11 @@ import { Hono } from "hono"; import { CheckoutService } from "../src/api/checkout"; import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; -import { ExtendedCheckoutCreateRequestSchema } from "../src/models"; -import { prettyValidation } from "../src/utils/validation"; +import { + ExtendedCheckoutCreateRequestSchema, + ExtendedCheckoutUpdateRequestSchema, +} from "../src/models"; +import { IdParamSchema, prettyValidation } from "../src/utils/validation"; function buildApp() { const svc = new CheckoutService(); @@ -35,6 +38,12 @@ function buildApp() { zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), svc.createCheckout ); + app.put( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + zValidator("json", ExtendedCheckoutUpdateRequestSchema, prettyValidation), + svc.updateCheckout + ); return app; } @@ -63,6 +72,23 @@ async function create(app: ReturnType, lineItems: unknown) { }); } +async function update( + app: ReturnType, + id: string, + lineItems: unknown +) { + return app.request(`/checkout-sessions/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id, + currency: "USD", + line_items: lineItems, + payment: {}, + }), + }); +} + test("an unknown product id is rejected", async () => { const app = buildApp(); const res = await create(app, [ @@ -96,3 +122,87 @@ test("ordering within the available stock succeeds", async () => { ]); assert.equal(res.status, 201); }); + +test("checkout create rejects quantity 0 with 422", async () => { + const app = buildApp(); + const res = await create(app, [ + { item: { id: "bouquet_roses" }, quantity: 0 }, + ]); + assert.equal(res.status, 422); + const text = await res.text(); + assert.match(text, /line_items\[0\]\.quantity/); + assert.match(text, /greater than or equal to 1/i); +}); + +test("checkout create rejects negative quantity with 422", async () => { + const app = buildApp(); + const res = await create(app, [ + { item: { id: "bouquet_roses" }, quantity: -1 }, + ]); + assert.equal(res.status, 422); + const text = await res.text(); + assert.match(text, /line_items\[0\]\.quantity/); + assert.match(text, /greater than or equal to 1/i); +}); + +test("checkout create rejects non-integer quantity with 422", async () => { + const app = buildApp(); + const res = await create(app, [ + { item: { id: "bouquet_roses" }, quantity: 1.5 }, + ]); + assert.equal(res.status, 422); + const text = await res.text(); + assert.match(text, /line_items\[0\]\.quantity/); + assert.match(text, /Expected integer/i); +}); + +test("checkout update rejects quantity 0 with 422", async () => { + const app = buildApp(); + const createRes = await create(app, [ + { item: { id: "bouquet_roses" }, quantity: 1 }, + ]); + assert.equal(createRes.status, 201); + const checkout = (await createRes.json()) as { id: string }; + + const updateRes = await update(app, checkout.id, [ + { item: { id: "bouquet_roses" }, quantity: 0 }, + ]); + assert.equal(updateRes.status, 422); + const text = await updateRes.text(); + assert.match(text, /line_items\[0\]\.quantity/); + assert.match(text, /greater than or equal to 1/i); +}); + +test("checkout update rejects negative quantity with 422", async () => { + const app = buildApp(); + const createRes = await create(app, [ + { item: { id: "bouquet_roses" }, quantity: 1 }, + ]); + assert.equal(createRes.status, 201); + const checkout = (await createRes.json()) as { id: string }; + + const updateRes = await update(app, checkout.id, [ + { item: { id: "bouquet_roses" }, quantity: -1 }, + ]); + assert.equal(updateRes.status, 422); + const text = await updateRes.text(); + assert.match(text, /line_items\[0\]\.quantity/); + assert.match(text, /greater than or equal to 1/i); +}); + +test("checkout update rejects non-integer quantity with 422", async () => { + const app = buildApp(); + const createRes = await create(app, [ + { item: { id: "bouquet_roses" }, quantity: 1 }, + ]); + assert.equal(createRes.status, 201); + const checkout = (await createRes.json()) as { id: string }; + + const updateRes = await update(app, checkout.id, [ + { item: { id: "bouquet_roses" }, quantity: 2.5 }, + ]); + assert.equal(updateRes.status, 422); + const text = await updateRes.text(); + assert.match(text, /line_items\[0\]\.quantity/); + assert.match(text, /Expected integer/i); +});