From 7f6b546d9f7b87240a8b17557b81fe1f24235a38 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Wed, 19 Aug 2026 23:56:22 -0400 Subject: [PATCH] fix(rest/nodejs): stop copying client supplied omit members into the checkout response The create handler builds the response by spreading the request body over the explicit keys. checkout.json marks continue_url, expires_at, messages and order as ucp_request omit, and none of the four has an explicit key, so a request that carried one saw its value come back in the 201 and persist into the stored session. The spread now drops all four, matching the handling the explicit keys already give ucp, id, status, totals, links and currency. Same defect class as the currency read fixed in #156 and the id fixed in #167 on the Python server. --- rest/nodejs/src/api/checkout.ts | 20 +++- rest/nodejs/test/omit_fields.test.ts | 135 +++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 rest/nodejs/test/omit_fields.test.ts diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index e1ef14e..dc7fda1 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -660,7 +660,25 @@ export class CheckoutService { }); } - const { fulfillment: _reqFulfillment, ...requestBody } = request; + // checkout.json marks continue_url, expires_at, messages and order as + // ucp_request: omit, so the business owns them on the response and they + // must not ride from the request into the response through the spread + // below. ucp, id, status, totals, links and currency are already + // overwritten by explicit keys; these four have no explicit key, so + // they are dropped here. + const { + fulfillment: _reqFulfillment, + continue_url: _reqContinueUrl, + expires_at: _reqExpiresAt, + messages: _reqMessages, + order: _reqOrder, + ...requestBody + } = request as typeof request & { + continue_url?: unknown; + expires_at?: unknown; + messages?: unknown; + order?: unknown; + }; const fulfillment = this.constructFulfillmentResponse( _reqFulfillment, diff --git a/rest/nodejs/test/omit_fields.test.ts b/rest/nodejs/test/omit_fields.test.ts new file mode 100644 index 0000000..63dd011 --- /dev/null +++ b/rest/nodejs/test/omit_fields.test.ts @@ -0,0 +1,135 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// checkout.json marks continue_url, expires_at, messages and order as +// ucp_request: omit, so the business owns them on the response. The create +// handler copies the request body into the response with a spread, so a +// client that sends one of these members must not see its value come back +// or persist. + +import assert from "node:assert/strict"; +import { before, test } from "node:test"; + +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { CheckoutService } from "../src/api/checkout"; +import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; +import { ExtendedCheckoutCreateRequestSchema } from "../src/models"; +import { IdParamSchema, prettyValidation } from "../src/utils/validation"; + +const JSON_HEADERS = { "Content-Type": "application/json" }; + +function buildApp() { + const service = new CheckoutService(); + const app = new Hono<{ Variables: { logger: typeof console } }>(); + app.use(async (c, next) => { + c.set("logger", console); + await next(); + }); + app.post( + "/checkout-sessions", + zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), + service.createCheckout + ); + app.get( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + service.getCheckout + ); + return app; +} + +before(() => { + initDbs(":memory:", ":memory:"); + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("bouquet_roses", "Red Rose", 3500, ""); + getTransactionsDb() + .prepare("INSERT INTO inventory (product_id, quantity) VALUES (?, ?)") + .run("bouquet_roses", 100); +}); + +const CLIENT_VALUES = { + continue_url: "https://platform.example/client-chosen", + expires_at: "2030-01-01T00:00:00Z", + messages: [{ type: "info", code: "custom", content: "client supplied text" }], + order: { + id: "order_client_chosen", + checkout_session_id: "fake", + permalink_url: "https://platform.example/order", + }, +}; + +test("create does not adopt client supplied omit members", async () => { + const app = buildApp(); + const res = await app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + ...CLIENT_VALUES, + }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.notEqual( + body.continue_url, + CLIENT_VALUES.continue_url, + "continue_url is business owned" + ); + assert.notEqual( + body.expires_at, + CLIENT_VALUES.expires_at, + "expires_at is business owned" + ); + const contents = (body.messages ?? []).map( + (m: { content?: string }) => m.content + ); + assert.ok( + !contents.includes("client supplied text"), + "messages are business owned" + ); + assert.notEqual( + body.order?.id, + CLIENT_VALUES.order.id, + "order is business owned" + ); + + // And nothing persisted: read the session back. + const got = await app.request(`/checkout-sessions/${body.id}`); + assert.equal(got.status, 200); + const stored = await got.json(); + assert.notEqual(stored.continue_url, CLIENT_VALUES.continue_url); + assert.notEqual(stored.expires_at, CLIENT_VALUES.expires_at); + const storedContents = (stored.messages ?? []).map( + (m: { content?: string }) => m.content + ); + assert.ok(!storedContents.includes("client supplied text")); + assert.notEqual(stored.order?.id, CLIENT_VALUES.order.id); +}); + +test("a create without these members is unaffected", async () => { + const app = buildApp(); + const res = await app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + }), + }); + assert.equal(res.status, 201); +});