diff --git a/rest/nodejs/src/utils/validation.ts b/rest/nodejs/src/utils/validation.ts index 01fd2df..cb82a10 100644 --- a/rest/nodejs/src/utils/validation.ts +++ b/rest/nodejs/src/utils/validation.ts @@ -15,6 +15,8 @@ import { type Context, type Env } from "hono"; import * as z from "zod"; +import { UcpError, ucpErrorResponse } from "./ucp_error"; + /** * Middleware to handle Zod validation results. * Logs the validation status and returns a 422 error with a pretty-printed message if validation fails. @@ -60,7 +62,14 @@ export function prettyValidation( .join("\n"); c.var.logger.warn(prettyError); - return c.text(prettyError, 422); + // checkout-rest.md shapes protocol errors as a JSON body carrying code and + // content inside the UCP envelope. A request rejected by payload validation + // is a protocol error like any other, so it answers in that shape; the + // pretty diagnostic stays, as the envelope's content. + return ucpErrorResponse( + c, + new UcpError(prettyError, "INVALID_REQUEST", 422) + ); } } diff --git a/rest/nodejs/test/validation_envelope.test.ts b/rest/nodejs/test/validation_envelope.test.ts new file mode 100644 index 0000000..130ecbf --- /dev/null +++ b/rest/nodejs/test/validation_envelope.test.ts @@ -0,0 +1,100 @@ +// 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-rest.md gives protocol errors one shape: a JSON body carrying +// code and content inside the UCP envelope. Requests rejected by payload +// validation are protocol errors like any other, so they must speak that +// shape too — a plain-text diagnostic gives the platform nothing to parse. + +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 { UCP_VERSION } from "../src/utils/config"; +import { 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 + ); + 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); +}); + +async function create(app: ReturnType, body: object) { + return app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(body), + }); +} + +test("validation failure answers with the UCP error envelope, not plain text", async () => { + const app = buildApp(); + const res = await create(app, { line_items: "not-an-array" }); + assert.equal(res.status, 422); + assert.match( + res.headers.get("content-type") ?? "", + /application\/json/, + "validation errors must be JSON, not text" + ); + const body = await res.json(); + assert.equal(body.ucp?.status, "error", "ucp.status must be 'error'"); + assert.equal(body.ucp?.version, UCP_VERSION); + assert.ok( + Array.isArray(body.messages) && body.messages.length > 0, + "messages[] must carry the failure" + ); + const msg = body.messages[0]; + assert.equal(msg.type, "error"); + assert.ok(msg.code, "code must be present"); + assert.ok( + typeof msg.content === "string" && msg.content.includes("line_items"), + "content must name the offending member" + ); +}); + +test("a valid create still succeeds after the envelope change", async () => { + const app = buildApp(); + const res = await create(app, { + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + }); + assert.equal(res.status, 201); +});