Checkout: forward only the checkout fields, show clear errors, fix the contact form #16

Open
libretech wants to merge 6 commits from fix/checkout-errors into main
3 changed files with 254 additions and 2 deletions
Showing only changes of commit bfcbbfac39 - Show all commits
+7 -2
View File
@@ -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 }
});
});
+79
View File
@@ -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<Record<CustomerUpdateField, unknown>>;
/**
* 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<string, unknown> => 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 }
});
+168
View File
@@ -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<string, unknown>).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"] }
});
});