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 libretech/mp#71
This commit is contained in:
2026-10-09 02:32:20 +02:00
parent 5c773a99e6
commit bfcbbfac39
3 changed files with 254 additions and 2 deletions
+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"] }
});
});