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; + } + } +}