From 5c773a99e6af11dd60ad37ffb3a6743337a5677e Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 9 Oct 2026 02:32:14 +0200 Subject: [PATCH 1/6] feat(orders): pass the CMS's status and safe error fields to the browser The order routes (get, put, add-product, remove-product, checkout, capture) call forwardToCms, which throws a CMS error on as createError({ statusCode, statusMessage, data: { message, errors?, missing? } }), built by the pure shopErrorFromCms (server/utils/cmsError.ts). Before, the FetchError was thrown on as it was: the browser got the CMS's status, but Nitro treated it as unhandled, answered "Server Error" without data and logged every CMS 4xx as [unhandled]. Now the browser also gets the CMS's message, the errors of a rejected update and the fields a checkout misses, and no other field. A status that is the shop's own fault (401, 403, ...) is answered 500, a CMS that does not answer 503; 5xx are logged without the query and the order uuid. npm test runs tests/unit with Node's type stripping and no dependencies, as in libreshop/cms. nuxt.config keeps tests/ out of the app's type check. Refs https://git.librete.ch/libretech/mp/issues/71 --- nuxt.config.ts | 6 +- package.json | 3 +- server/api/orders/[uuid].get.ts | 4 +- server/api/orders/[uuid].put.ts | 4 +- .../[uuid]/add-product/[productId].put.ts | 4 +- .../[uuid]/capture/[paypalOrderId].post.ts | 4 +- server/api/orders/[uuid]/checkout.post.ts | 4 +- .../[uuid]/remove-product/[productId].put.ts | 4 +- server/utils/cmsApi.ts | 17 ++ server/utils/cmsError.ts | 81 +++++++ tests/unit/cmsError.test.ts | 201 ++++++++++++++++++ tests/unit/support/register.mjs | 6 + tests/unit/support/resolve-ts.mjs | 14 ++ 13 files changed, 338 insertions(+), 14 deletions(-) create mode 100644 server/utils/cmsError.ts create mode 100644 tests/unit/cmsError.test.ts create mode 100644 tests/unit/support/register.mjs create mode 100644 tests/unit/support/resolve-ts.mjs diff --git a/nuxt.config.ts b/nuxt.config.ts index d225b10..67c6312 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -72,7 +72,11 @@ export default defineNuxtConfig({ // TypeScript typescript: { - strict: true + strict: true, + // The unit tests run on Node (npm test), not in the app: keep them out of the app's type check (.nuxt/tsconfig.app.json). + tsConfig: { + exclude: ["../tests/**/*"] + } }, // Tailwind diff --git a/package.json b/package.json index befa466..d75fce2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "nuxt dev", "generate": "nuxt generate", "preview": "nuxt preview", - "postinstall": "nuxt prepare" + "postinstall": "nuxt prepare", + "test": "node --experimental-strip-types --import ./tests/unit/support/register.mjs --test 'tests/unit/**/*.test.ts'" }, "dependencies": { "@headlessui/vue": "^1.7.23", diff --git a/server/api/orders/[uuid].get.ts b/server/api/orders/[uuid].get.ts index e5b75c3..d5800d2 100644 --- a/server/api/orders/[uuid].get.ts +++ b/server/api/orders/[uuid].get.ts @@ -1,4 +1,4 @@ -import { fetchCms } from "~/server/utils/cmsApi"; +import { forwardToCms } from "~/server/utils/cmsApi"; export default defineEventHandler(async (event) => { const uuid = getRouterParam(event, "uuid"); @@ -6,5 +6,5 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: "Missing order UUID" }); } - return await fetchCms(`/orders/${uuid}/cart`); + return await forwardToCms(`/orders/${uuid}/cart`); }); diff --git a/server/api/orders/[uuid].put.ts b/server/api/orders/[uuid].put.ts index 6a16d98..de2ac64 100644 --- a/server/api/orders/[uuid].put.ts +++ b/server/api/orders/[uuid].put.ts @@ -1,4 +1,4 @@ -import { fetchCms } from "~/server/utils/cmsApi"; +import { forwardToCms } from "~/server/utils/cmsApi"; export default defineEventHandler(async (event) => { const uuid = getRouterParam(event, "uuid"); @@ -8,7 +8,7 @@ export default defineEventHandler(async (event) => { const body = await readBody(event); - return await fetchCms(`/orders/${uuid}/cart`, { + return await forwardToCms(`/orders/${uuid}/cart`, { method: "PUT", body }); diff --git a/server/api/orders/[uuid]/add-product/[productId].put.ts b/server/api/orders/[uuid]/add-product/[productId].put.ts index eb70602..db1411b 100644 --- a/server/api/orders/[uuid]/add-product/[productId].put.ts +++ b/server/api/orders/[uuid]/add-product/[productId].put.ts @@ -1,4 +1,4 @@ -import { fetchCms } from "~/server/utils/cmsApi"; +import { forwardToCms } from "~/server/utils/cmsApi"; export default defineEventHandler(async (event) => { const uuid = getRouterParam(event, "uuid"); @@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => { const count = query.count || 1; - return await fetchCms(`/orders/${uuid}/add-product/${productId}?count=${count}`, { + return await forwardToCms(`/orders/${uuid}/add-product/${productId}?count=${count}`, { method: "PUT" }); }); diff --git a/server/api/orders/[uuid]/capture/[paypalOrderId].post.ts b/server/api/orders/[uuid]/capture/[paypalOrderId].post.ts index ff7da80..84c338d 100644 --- a/server/api/orders/[uuid]/capture/[paypalOrderId].post.ts +++ b/server/api/orders/[uuid]/capture/[paypalOrderId].post.ts @@ -1,4 +1,4 @@ -import { fetchCms } from "~/server/utils/cmsApi"; +import { forwardToCms } from "~/server/utils/cmsApi"; export default defineEventHandler(async (event) => { const uuid = getRouterParam(event, "uuid"); @@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: "Missing PayPal order ID" }); } - return await fetchCms(`/orders/${uuid}/capture/${paypalOrderId}`, { + return await forwardToCms(`/orders/${uuid}/capture/${paypalOrderId}`, { method: "POST" }); }); diff --git a/server/api/orders/[uuid]/checkout.post.ts b/server/api/orders/[uuid]/checkout.post.ts index 856842f..07c3765 100644 --- a/server/api/orders/[uuid]/checkout.post.ts +++ b/server/api/orders/[uuid]/checkout.post.ts @@ -1,4 +1,4 @@ -import { fetchCms } from "~/server/utils/cmsApi"; +import { forwardToCms } from "~/server/utils/cmsApi"; export default defineEventHandler(async (event) => { const uuid = getRouterParam(event, "uuid"); @@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => { const returnUrl = query.returnUrl || ""; - return await fetchCms(`/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(String(returnUrl))}`, { + return await forwardToCms(`/orders/${uuid}/checkout?returnUrl=${encodeURIComponent(String(returnUrl))}`, { method: "POST" }); }); diff --git a/server/api/orders/[uuid]/remove-product/[productId].put.ts b/server/api/orders/[uuid]/remove-product/[productId].put.ts index 6d61fa1..9d4f0b5 100644 --- a/server/api/orders/[uuid]/remove-product/[productId].put.ts +++ b/server/api/orders/[uuid]/remove-product/[productId].put.ts @@ -1,4 +1,4 @@ -import { fetchCms } from "~/server/utils/cmsApi"; +import { forwardToCms } from "~/server/utils/cmsApi"; export default defineEventHandler(async (event) => { const uuid = getRouterParam(event, "uuid"); @@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => { const count = query.count || 1; - return await fetchCms(`/orders/${uuid}/remove-product/${productId}?count=${count}`, { + return await forwardToCms(`/orders/${uuid}/remove-product/${productId}?count=${count}`, { method: "PUT" }); }); diff --git a/server/utils/cmsApi.ts b/server/utils/cmsApi.ts index 8d61902..f1f05e7 100644 --- a/server/utils/cmsApi.ts +++ b/server/utils/cmsApi.ts @@ -1,3 +1,5 @@ +import { cmsErrorLogLine, shopErrorFromCms } from "./cmsError"; + /** * Server-side CMS API utility. * Use this for direct CMS access during SSR. @@ -15,3 +17,18 @@ export async function fetchCms(endpoint: string, options: Parameters(endpoint: string, options: Parameters[1] = {}): Promise { + try { + return await fetchCms(endpoint, options); + } catch (error) { + const shopError = shopErrorFromCms(error); + if (!shopError) throw error; + if (shopError.statusCode >= 500) console.error(cmsErrorLogLine(String(options.method ?? "GET"), endpoint, shopError)); + throw createError(shopError); + } +} diff --git a/server/utils/cmsError.ts b/server/utils/cmsError.ts new file mode 100644 index 0000000..ac97387 --- /dev/null +++ b/server/utils/cmsError.ts @@ -0,0 +1,81 @@ +// How a failed CMS request answers the browser: with the CMS's status and only the fields of its error that are safe to show. +// The CMS (Strapi) answers an error as { data: null, error: { status, name, message, details } }, which $fetch throws as ofetch's +// FetchError with the response's status and parsed body. Thrown on as it is, Nitro treats it as unhandled: it hides the message and +// the data from the browser and logs the CMS URL. forwardToCms (cmsApi.ts) throws createError(shopErrorFromCms(error)) instead. +// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/cmsError.test.ts. + +/** What the browser receives as the data of the error. errors and missing are the CMS's details.errors and details.missing. */ +export type ShopErrorData = { message: string; errors?: string[]; missing?: string[] }; + +/** The argument for h3's createError. */ +export type ShopError = { statusCode: number; statusMessage: string; data: ShopErrorData }; + +// The statuses passed on: the CMS's answers about the order and its payment. Any other status, such as 401 or 403 for a wrong +// API token, is the shop's own fault and answered 500. +const STATUS_TEXTS = new Map([ + [400, "Bad Request"], + [404, "Not Found"], + [409, "Conflict"], + [500, "Internal Server Error"], + [502, "Bad Gateway"], + [503, "Service Unavailable"], + [504, "Gateway Timeout"] +]); + +const MAX_MESSAGE_LENGTH = 200; +const MAX_STATUS_MESSAGE_LENGTH = 100; +const MAX_FIELD_LENGTH = 64; +const MAX_LIST_LENGTH = 20; + +const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + +const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +const cut = (text: string, maxLength: number): string => (text.length > maxLength ? `${text.slice(0, maxLength)}…` : text); + +// The strings of a list in the CMS's details, e.g. ["data.email: must be an email address of at most 254 characters"]. +const stringList = (value: unknown, maxLength: number): string[] | undefined => { + if (!Array.isArray(value)) return undefined; + const list = value + .filter((item): item is string => typeof item === "string") + .slice(0, MAX_LIST_LENGTH) + .map((item) => cut(item, maxLength)); + return list.length > 0 ? list : undefined; +}; + +// The status message is the HTTP reason phrase, which allows printable ASCII only. +const isReasonPhrase = (text: string): boolean => text.length <= MAX_STATUS_MESSAGE_LENGTH && /^[\x20-\x7e]+$/.test(text); + +/** + * The error to throw to the browser for an error of $fetch to the CMS, or undefined if the error is not ofetch's FetchError + * (a bug, to rethrow as it is). The CMS's status is kept for 400, 404, 409, 500, 502, 503 and 504, any other is answered 500, + * and a CMS that does not answer 503. The data holds the CMS's message, details.errors and details.missing, and nothing else. + */ +export const shopErrorFromCms = (error: unknown): ShopError | undefined => { + if (!(error instanceof Error) || error.name !== "FetchError") return undefined; + const { status, data } = error as Error & { status?: unknown; data?: unknown }; + + if (typeof status !== "number") { + return { statusCode: 503, statusMessage: "Service Unavailable", data: { message: "The CMS did not answer" } }; + } + const statusText = STATUS_TEXTS.get(status); + if (statusText === undefined) { + return { statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } }; + } + + const cmsError: Record = isObject(data) && isObject(data.error) ? data.error : {}; + const details: Record = isObject(cmsError.details) ? cmsError.details : {}; + const message = typeof cmsError.message === "string" && cmsError.message !== "" ? cut(cmsError.message, MAX_MESSAGE_LENGTH) : statusText; + const errors = stringList(details.errors, MAX_MESSAGE_LENGTH); + const missing = stringList(details.missing, MAX_FIELD_LENGTH); + + return { + statusCode: status, + statusMessage: isReasonPhrase(message) ? message : statusText, + data: { message, ...(errors ? { errors } : {}), ...(missing ? { missing } : {}) } + }; +}; + +/** The log line for a failed CMS request: without its query, and with order uuids replaced, since a uuid opens its order. */ +export const cmsErrorLogLine = (method: string, endpoint: string, error: ShopError): string => + `[cms] ${method.toUpperCase()} ${endpoint.replace(/\?.*$/, "").replace(UUID, ":uuid")}: ${error.statusCode} ${error.data.message}`; diff --git a/tests/unit/cmsError.test.ts b/tests/unit/cmsError.test.ts new file mode 100644 index 0000000..a980443 --- /dev/null +++ b/tests/unit/cmsError.test.ts @@ -0,0 +1,201 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { cmsErrorLogLine, shopErrorFromCms } from "../../server/utils/cmsError.ts"; + +const UUID = "11111111-2222-4333-8444-555555555555"; +const CART = `http://cms:5555/api/orders/${UUID}/cart`; +const CHECKOUT = `http://cms:5555/api/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example%2Fcheckout%2F3`; +const CAPTURE = `http://cms:5555/api/orders/${UUID}/capture/5O190127TN364715T`; + +// Built like ofetch's createFetchError (ofetch 1.5): an Error named FetchError, with getters for the response's status and parsed body. +const fetchError = (method: string, url: string, response?: { status: number; statusText: string; body: unknown }): Error => { + const status = response ? `${response.status} ${response.statusText}` : " fetch failed"; + const error = new Error(`[${method}] ${JSON.stringify(url)}: ${status}`); + error.name = "FetchError"; + const fields: [string, unknown][] = [ + ["data", response?.body], + ["status", response?.status], + ["statusCode", response?.status], + ["statusText", response?.statusText], + ["statusMessage", response?.statusText] + ]; + for (const [key, value] of fields) Object.defineProperty(error, key, { get: () => value }); + return error; +}; + +// Strapi's error body, as ctx.badRequest, ctx.notFound, ctx.conflict and ctx.badGateway write it. +const strapiError = (status: number, name: string, message: string, details: Record = {}) => ({ + data: null, + error: { status, name, message, details } +}); + +const cmsAnswer = (method: string, url: string, status: number, statusText: string, body: unknown) => + shopErrorFromCms(fetchError(method, url, { status, statusText, body })); + +test("passes a rejected order update on as 400 with the CMS's errors", () => { + const errors = ["data.email: must be an email address of at most 254 characters", "data.total: not a field the customer may set"]; + + assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", strapiError(400, "BadRequestError", "Invalid order update", { errors })), { + statusCode: 400, + statusMessage: "Invalid order update", + data: { message: "Invalid order update", errors } + }); +}); + +test("passes a checkout that is not ready on as 400 with the fields it misses", () => { + const body = strapiError(400, "BadRequestError", "Order is not ready for checkout", { missing: ["email", "delivery"] }); + + assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), { + statusCode: 400, + statusMessage: "Order is not ready for checkout", + data: { message: "Order is not ready for checkout", missing: ["email", "delivery"] } + }); +}); + +test("passes on no detail but errors and missing", () => { + const details = { reason: "deliveryAddress has no postal code", order: { email: "erika@example.org" }, errors: "data.email" }; + const body = { ...strapiError(400, "BadRequestError", "Order is not ready for checkout", details), meta: { email: "erika@example.org" } }; + + assert.deepEqual(cmsAnswer("POST", CHECKOUT, 400, "Bad Request", body), { + statusCode: 400, + statusMessage: "Order is not ready for checkout", + data: { message: "Order is not ready for checkout" } + }); +}); + +test("passes the conflicts of a paid order, an unavailable product and a payment that does not fit the order on as 409", () => { + for (const [url, message] of [ + [CART, "Order can no longer be changed"], + [CHECKOUT, "A product in the cart is no longer available"], + [CAPTURE, "Order is already paid"], + [CAPTURE, "Payment does not belong to this order"], + [CAPTURE, "Payment does not match this order"], + [CAPTURE, "Order changed during the payment"] + ] as const) { + assert.deepEqual( + cmsAnswer("POST", url, 409, "Conflict", strapiError(409, "ConflictError", message)), + { statusCode: 409, statusMessage: message, data: { message } }, + message + ); + } +}); + +test("passes a missing order on as 404", () => { + assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "Order not found")), { + statusCode: 404, + statusMessage: "Order not found", + data: { message: "Order not found" } + }); +}); + +test("passes PayPal's errors on as 502", () => { + for (const [url, message] of [ + [CHECKOUT, "Could not create the PayPal order"], + [CAPTURE, "PayPal could not capture the payment"], + [CAPTURE, "Payment could not be confirmed"] + ] as const) { + assert.deepEqual( + cmsAnswer("POST", url, 502, "Bad Gateway", strapiError(502, "BadGatewayError", message)), + { statusCode: 502, statusMessage: message, data: { message } }, + message + ); + } +}); + +test("passes a payment the CMS could not record on as 500 with its message", () => { + const body = strapiError(500, "InternalServerError", "Payment could not be recorded"); + + assert.deepEqual(cmsAnswer("POST", CAPTURE, 500, "Internal Server Error", body), { + statusCode: 500, + statusMessage: "Payment could not be recorded", + data: { message: "Payment could not be recorded" } + }); +}); + +test("answers 500 without the CMS's message for a status that is the shop's own fault", () => { + for (const [status, statusText] of [ + [401, "Unauthorized"], + [403, "Forbidden"], + [405, "Method Not Allowed"], + [413, "Payload Too Large"] + ] as const) { + assert.deepEqual( + cmsAnswer("PUT", CART, status, statusText, strapiError(status, "Error", "Missing or invalid credentials")), + { statusCode: 500, statusMessage: "Internal Server Error", data: { message: "Internal Server Error" } }, + String(status) + ); + } +}); + +test("answers 503 when the CMS does not answer", () => { + assert.deepEqual(shopErrorFromCms(fetchError("GET", CART)), { + statusCode: 503, + statusMessage: "Service Unavailable", + data: { message: "The CMS did not answer" } + }); +}); + +test("leaves any other error to be rethrown as it is", () => { + assert.equal(shopErrorFromCms(new TypeError("Cannot read properties of undefined (reading 'uuid')")), undefined); + assert.equal(shopErrorFromCms({ name: "FetchError", status: 409, data: strapiError(409, "ConflictError", "Order is already paid") }), undefined); + assert.equal(shopErrorFromCms("FetchError"), undefined); + assert.equal(shopErrorFromCms(undefined), undefined); +}); + +test("keeps only the strings of the CMS's lists, at most 20, and cuts a long one", () => { + // Strapi's own validation errors are objects with the path and the message: they are dropped. + const objects = strapiError(400, "ValidationError", "Invalid order update", { + errors: [{ path: ["email"], message: "email must be a valid email" }] + }); + assert.deepEqual(cmsAnswer("PUT", CART, 400, "Bad Request", objects)?.data, { message: "Invalid order update" }); + + const many = Array.from({ length: 25 }, (_, index) => `data.field${index}: not a field the customer may set`); + const long = `data.${"x".repeat(300)}: not a field the customer may set`; + const body = strapiError(400, "BadRequestError", "Invalid order update", { errors: [long, 7, ...many] }); + const data = cmsAnswer("PUT", CART, 400, "Bad Request", body)?.data; + + assert.equal(data?.errors?.length, 20); + assert.equal(data?.errors?.[0], `${long.slice(0, 200)}…`); + assert.equal(data?.errors?.[1], many[0]); +}); + +test("answers with the status's reason phrase when the body is no CMS error", () => { + assert.deepEqual(cmsAnswer("POST", CHECKOUT, 502, "Bad Gateway", "502 Bad Gateway"), { + statusCode: 502, + statusMessage: "Bad Gateway", + data: { message: "Bad Gateway" } + }); + assert.deepEqual(cmsAnswer("GET", CART, 404, "Not Found", strapiError(404, "NotFoundError", "")), { + statusCode: 404, + statusMessage: "Not Found", + data: { message: "Not Found" } + }); +}); + +test("keeps a message the status line cannot carry in the data only", () => { + const long = `Order ${"x".repeat(250)}`; + + assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", "Bestellung gesperrt…")), { + statusCode: 409, + statusMessage: "Conflict", + data: { message: "Bestellung gesperrt…" } + }); + assert.deepEqual(cmsAnswer("PUT", CART, 409, "Conflict", strapiError(409, "ConflictError", long)), { + statusCode: 409, + statusMessage: "Conflict", + data: { message: `${long.slice(0, 200)}…` } + }); +}); + +test("logs a failed request without its query and without the order's uuid", () => { + const error = { statusCode: 502, statusMessage: "Bad Gateway", data: { message: "Could not create the PayPal order" } }; + + assert.equal( + cmsErrorLogLine("post", `/orders/${UUID}/checkout?returnUrl=https%3A%2F%2Fshop.example`, error), + "[cms] POST /orders/:uuid/checkout: 502 Could not create the PayPal order" + ); + assert.equal( + cmsErrorLogLine("GET", `/orders/${UUID.toUpperCase()}/cart`, error), + "[cms] GET /orders/:uuid/cart: 502 Could not create the PayPal order" + ); +}); diff --git a/tests/unit/support/register.mjs b/tests/unit/support/register.mjs new file mode 100644 index 0000000..ac8feee --- /dev/null +++ b/tests/unit/support/register.mjs @@ -0,0 +1,6 @@ +// Loaded by `npm test` through `node --import`. Node runs the .ts sources directly (type stripping), but unlike Vite and Nitro it does +// not resolve extensionless relative imports such as `import { … } from "./cmsError"` in a server/utils module. +// The hook below retries such an import from a .ts file with ".ts" appended. Test files import sources with explicit .ts paths. +import { register } from "node:module"; + +register("./resolve-ts.mjs", import.meta.url); diff --git a/tests/unit/support/resolve-ts.mjs b/tests/unit/support/resolve-ts.mjs new file mode 100644 index 0000000..08d3b07 --- /dev/null +++ b/tests/unit/support/resolve-ts.mjs @@ -0,0 +1,14 @@ +// Module resolve hook, registered by ./register.mjs: `./cmsError` imported from a .ts file resolves to `./cmsError.ts`. +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (error) { + const relative = specifier.startsWith("./") || specifier.startsWith("../"); + if (error?.code !== "ERR_MODULE_NOT_FOUND" || !relative || !context.parentURL?.endsWith(".ts")) throw error; + try { + return await nextResolve(`${specifier}.ts`, context); + } catch { + throw error; + } + } +} -- 2.36.6 From bfcbbfac393687cf14f05a62969fb85ba74cbfff Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 9 Oct 2026 02:32:20 +0200 Subject: [PATCH 2/6] fix(orders): forward only the checkout's fields on an order update PUT /api/orders/:uuid forwarded the browser's body to the CMS unchanged. It now forwards { data } with only the seven fields of the checkout's steps: email, acceptedTermsAndConditionsAt, invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured and delivery. Any other field, a field beside data, or a body of another shape is answered 400 "Invalid order update" with the CMS's error format, without calling the CMS. The values are left to the CMS, which checks them and stays the authority; this is defence in depth. pickCustomerUpdate (server/utils/customerUpdate.ts) is pure and tested with the exact payloads of steps 1 and 2 and with every server-only attribute of the order. Refs https://git.librete.ch/libretech/mp/issues/71 --- server/api/orders/[uuid].put.ts | 9 +- server/utils/customerUpdate.ts | 79 ++++++++++++++ tests/unit/customerUpdate.test.ts | 168 ++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 server/utils/customerUpdate.ts create mode 100644 tests/unit/customerUpdate.test.ts diff --git a/server/api/orders/[uuid].put.ts b/server/api/orders/[uuid].put.ts index de2ac64..559f705 100644 --- a/server/api/orders/[uuid].put.ts +++ b/server/api/orders/[uuid].put.ts @@ -1,4 +1,5 @@ import { forwardToCms } from "~/server/utils/cmsApi"; +import { invalidOrderUpdate, pickCustomerUpdate } from "~/server/utils/customerUpdate"; export default defineEventHandler(async (event) => { const uuid = getRouterParam(event, "uuid"); @@ -6,10 +7,14 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: "Missing order UUID" }); } - const body = await readBody(event); + // Only the fields of the checkout's steps reach the CMS, which checks their values. Any other field is answered 400 here. + const update = pickCustomerUpdate(await readBody(event)); + if (update.ok === false) { + throw createError(invalidOrderUpdate(update.errors)); + } return await forwardToCms(`/orders/${uuid}/cart`, { method: "PUT", - body + body: { data: update.data } }); }); diff --git a/server/utils/customerUpdate.ts b/server/utils/customerUpdate.ts new file mode 100644 index 0000000..b24319d --- /dev/null +++ b/server/utils/customerUpdate.ts @@ -0,0 +1,79 @@ +// What PUT /api/orders/:uuid forwards to the CMS: the fields of the checkout's steps, and nothing else. +// Step 1 (pages/checkout/1.vue) sends { data: { email, acceptedTermsAndConditionsAt } }, step 2 (pages/checkout/2.vue) sends +// { data: { invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured, delivery } }. +// The CMS checks the values and stays the authority (libreshop/cms src/checkout/customer-update.ts). This check is defence in depth: +// a body with any other field is answered 400 without calling the CMS, in the format of the CMS's own answer. +// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/customerUpdate.test.ts. +import type { ShopError } from "./cmsError"; + +/** The order fields a customer may set, in the order of the checkout's steps. */ +export const CUSTOMER_UPDATE_FIELDS = [ + "email", + "acceptedTermsAndConditionsAt", + "invoiceAddress", + "deliveryAddress", + "invoiceAddressStructured", + "deliveryAddressStructured", + "delivery" +] as const; + +export type CustomerUpdateField = (typeof CUSTOMER_UPDATE_FIELDS)[number]; + +/** The fields to forward, with the values the browser sent: the CMS checks them. */ +export type CustomerUpdate = Partial>; + +/** + * unknown: the names of the rejected fields, such as "data.paymentAuthorised", or "email" for a field sent beside data. + * errors: one message per rejected field or malformed part, in the CMS's format "name: reason". + */ +export type PickedCustomerUpdate = { ok: true; data: CustomerUpdate } | { ok: false; unknown: string[]; errors: string[] }; + +export const INVALID_ORDER_UPDATE = "Invalid order update"; + +// A field name is chosen by the client and ends up in the answer, so a long one is cut. +const MAX_NAME_LENGTH = 64; + +const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +const isCustomerUpdateField = (key: string): key is CustomerUpdateField => (CUSTOMER_UPDATE_FIELDS as readonly string[]).includes(key); + +const fieldName = (key: string): string => (key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}…` : key); + +/** Picks the fields a customer may set from the body { data: { … } }. Any other field, or another shape, rejects the whole body. */ +export const pickCustomerUpdate = (body: unknown): PickedCustomerUpdate => { + if (!isObject(body)) { + return { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] }; + } + + const unknown: string[] = []; + const errors: string[] = []; + for (const key of Object.keys(body)) { + if (key === "data") continue; + unknown.push(fieldName(key)); + errors.push(`${fieldName(key)}: not accepted, the fields belong in data`); + } + + const fields = body.data; + const data: CustomerUpdate = {}; + if (!isObject(fields)) { + errors.push(fields === undefined ? "data: missing" : "data: must be an object"); + } else { + for (const key of Object.keys(fields)) { + if (isCustomerUpdateField(key)) { + data[key] = fields[key]; + } else { + unknown.push(`data.${fieldName(key)}`); + errors.push(`data.${fieldName(key)}: not a field the customer may set`); + } + } + } + + return errors.length > 0 ? { ok: false, unknown, errors } : { ok: true, data }; +}; + +/** The 400 for a rejected body: the shape the shop answers for the CMS's own 400 (cmsError.ts). */ +export const invalidOrderUpdate = (errors: string[]): ShopError => ({ + statusCode: 400, + statusMessage: INVALID_ORDER_UPDATE, + data: { message: INVALID_ORDER_UPDATE, errors } +}); diff --git a/tests/unit/customerUpdate.test.ts b/tests/unit/customerUpdate.test.ts new file mode 100644 index 0000000..f281f2f --- /dev/null +++ b/tests/unit/customerUpdate.test.ts @@ -0,0 +1,168 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CUSTOMER_UPDATE_FIELDS, invalidOrderUpdate, pickCustomerUpdate } from "../../server/utils/customerUpdate.ts"; + +// The body as readBody hands it to the route: sent by useShopApi().updateOrder as JSON. +const overTheWire = (body: unknown): unknown => JSON.parse(JSON.stringify(body)); + +const address = { + givenName: "Erika", + familyName: "Mustermann", + streetAddress: "Musterstraße 1", + postalCode: "70190", + addressLevel2: "Stuttgart", + country: "DE" +}; +const otherAddress = { + givenName: "Max", + familyName: "Muster", + streetAddress: "Hauptstraße 5", + postalCode: "10115", + addressLevel2: "Berlin", + country: "DE" +}; + +// pages/checkout/1.vue: cart.update({ email, acceptedTermsAndConditionsAt }). +const step1 = { data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-09T08:15:00.000Z" } }; + +// pages/checkout/2.vue without a separate delivery address: the invoice address is sent as the delivery address too. +const step2 = { + data: { + invoiceAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart", + deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart", + invoiceAddressStructured: address, + deliveryAddressStructured: address, + delivery: 1 + } +}; + +// The order's attributes in the CMS (src/api/order/content-types/order/schema.json) that only the server writes, and Strapi's own. +const SERVER_FIELDS = [ + "id", + "uuid", + "date", + "customer", + "invoice", + "deliveryNote", + "hash", + "payment", + "VAT", + "subtotal", + "total", + "cart", + "paymentAuthorised", + "paymentStatus", + "paypalOrderId", + "paypalCaptureId", + "paymentCapturedAt", + "emailSent", + "invoiceSent", + "deliveryNoteSent", + "invoiceNumber", + "deliveryNoteNumber", + "deliveryTrackingNumber", + "createdAt", + "updatedAt", + "publishedAt" +]; + +test("lists the seven fields of the checkout's steps", () => { + assert.deepEqual( + [...CUSTOMER_UPDATE_FIELDS], + [ + "email", + "acceptedTermsAndConditionsAt", + "invoiceAddress", + "deliveryAddress", + "invoiceAddressStructured", + "deliveryAddressStructured", + "delivery" + ] + ); +}); + +test("passes the payload of checkout step 1 unchanged", () => { + assert.deepEqual(pickCustomerUpdate(overTheWire(step1)), { ok: true, data: step1.data }); +}); + +test("passes the payload of checkout step 2 unchanged", () => { + assert.deepEqual(pickCustomerUpdate(overTheWire(step2)), { ok: true, data: step2.data }); +}); + +test("passes the payload of checkout step 2 with a separate delivery address unchanged", () => { + const body = { + data: { + ...step2.data, + deliveryAddress: "Max Muster\nHauptstraße 5\n10115 Berlin", + deliveryAddressStructured: otherAddress, + delivery: 2 + } + }; + + assert.deepEqual(pickCustomerUpdate(overTheWire(body)), { ok: true, data: body.data }); +}); + +test("leaves the values to the CMS, which checks them", () => { + const body = { data: { email: "no address", delivery: null, invoiceAddressStructured: { street: "x" } } }; + + assert.deepEqual(pickCustomerUpdate(body), { ok: true, data: body.data }); +}); + +test("rejects each field only the server writes", () => { + for (const field of SERVER_FIELDS) { + assert.deepEqual( + pickCustomerUpdate({ data: { [field]: 1 } }), + { ok: false, unknown: [`data.${field}`], errors: [`data.${field}: not a field the customer may set`] }, + field + ); + } +}); + +test("rejects the whole body instead of dropping the field, and names every rejected field", () => { + const body = { data: { ...step1.data, paymentAuthorised: true, total: 0.01 } }; + + assert.deepEqual(pickCustomerUpdate(body), { + ok: false, + unknown: ["data.paymentAuthorised", "data.total"], + errors: ["data.paymentAuthorised: not a field the customer may set", "data.total: not a field the customer may set"] + }); +}); + +test("rejects a field sent beside data", () => { + assert.deepEqual(pickCustomerUpdate({ email: "erika@example.org", data: {} }), { + ok: false, + unknown: ["email"], + errors: ["email: not accepted, the fields belong in data"] + }); +}); + +test("rejects a body without data, with data that is not an object, and a body that is not an object", () => { + assert.deepEqual(pickCustomerUpdate({}), { ok: false, unknown: [], errors: ["data: missing"] }); + assert.deepEqual(pickCustomerUpdate({ data: [step1.data] }), { ok: false, unknown: [], errors: ["data: must be an object"] }); + assert.deepEqual(pickCustomerUpdate({ data: null }), { ok: false, unknown: [], errors: ["data: must be an object"] }); + for (const body of [undefined, null, "data", [step1]]) { + assert.deepEqual(pickCustomerUpdate(body), { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] }); + } +}); + +test("rejects __proto__ and constructor without touching any prototype", () => { + const result = pickCustomerUpdate(JSON.parse('{"data":{"__proto__":{"paymentAuthorised":true},"constructor":{"prototype":{}}}}')); + + assert.equal(result.ok, false); + assert.deepEqual(result.ok === false && result.unknown, ["data.__proto__", "data.constructor"]); + assert.equal(({} as Record).paymentAuthorised, undefined); +}); + +test("cuts a long field name in the answer", () => { + const result = pickCustomerUpdate({ data: { ["x".repeat(100)]: 1 } }); + + assert.deepEqual(result.ok === false && result.unknown, [`data.${"x".repeat(64)}…`]); +}); + +test("answers a rejected body with a 400 in the shape of the CMS's own 400", () => { + assert.deepEqual(invalidOrderUpdate(["data.total: not a field the customer may set"]), { + statusCode: 400, + statusMessage: "Invalid order update", + data: { message: "Invalid order update", errors: ["data.total: not a field the customer may set"] } + }); +}); -- 2.36.6 From 8931afc6701cc873c47f4469019c842027cdf303 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Fri, 9 Oct 2026 02:32:32 +0200 Subject: [PATCH 3/6] feat(checkout): show a German message when a checkout step fails checkoutErrorMessage (utils/checkoutError.ts) maps the status and body of a failed request, the shop's answer or a raw CMS (Strapi) error, to one fixed message for the customer, addressed with "du": an invalid e-mail address (when data.email is among the rejected fields), other invalid input, an order already paid, a product no longer available, an order not ready for checkout (naming the missing steps), a payment that does not fit the order or an order changed during the payment ("Bitte starte die Zahlung neu"), PayPal not reachable ("versuche es in ein paar Minuten noch einmal"), a payment PayPal may have taken that the CMS could not confirm or record ("Bitte bezahle nicht noch einmal"), and a general fallback. It never shows the server's text. Steps 1 and 2 show the message above their submit button, in the style of step 3's payment error, instead of logging the error only. Step 3 shows it for a failed PayPal order creation or capture. The e-mail input of step 1 is type="email" (Input.vue takes a type). Refs https://git.librete.ch/libretech/mp/issues/71 --- components/Input.vue | 5 +- pages/checkout/1.vue | 10 +- pages/checkout/2.vue | 8 ++ pages/checkout/3.vue | 6 +- tests/unit/checkoutError.test.ts | 185 +++++++++++++++++++++++++++++++ utils/checkoutError.ts | 98 ++++++++++++++++ 6 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 tests/unit/checkoutError.test.ts create mode 100644 utils/checkoutError.ts diff --git a/components/Input.vue b/components/Input.vue index 6be8034..8f47616 100644 --- a/components/Input.vue +++ b/components/Input.vue @@ -5,6 +5,7 @@ {{ label }} (), { - autocomplete: "off" + autocomplete: "off", + type: "text" } ); diff --git a/pages/checkout/1.vue b/pages/checkout/1.vue index 3d59010..0b0ffc3 100644 --- a/pages/checkout/1.vue +++ b/pages/checkout/1.vue @@ -29,7 +29,7 @@
- +
@@ -60,6 +64,7 @@