From b9670b7229e51e50ba1555e97b37e5d28cf0b667 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Thu, 8 Oct 2026 22:54:59 +0200 Subject: [PATCH] feat(order): parse the customer's order update against the checkout fields parseCustomerUpdate accepts { data } with a subset of the seven fields the shop's checkout sends: email, acceptedTermsAndConditionsAt, invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured and delivery. Any other key, at the top level, in data or in a structured address, rejects the whole body, and the errors name the fields without repeating their values. The terms are recorded at the server's time whenever the client sends a true value: the invoice is dated by it. Refs https://git.librete.ch/libretech/mp/issues/71 --- src/checkout/customer-update.ts | 146 ++++++++++++ tests/unit/customer-update.test.ts | 348 +++++++++++++++++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 src/checkout/customer-update.ts create mode 100644 tests/unit/customer-update.test.ts diff --git a/src/checkout/customer-update.ts b/src/checkout/customer-update.ts new file mode 100644 index 0000000..43dd8ba --- /dev/null +++ b/src/checkout/customer-update.ts @@ -0,0 +1,146 @@ +// What a customer may write to an order: the fields of the shop's checkout steps, and nothing else. +// The shop forwards the browser's body unchanged (libreshop/shop server/api/orders/[uuid].put.ts), so the body is untrusted. +// Step 1 (pages/checkout/1.vue) sends { data: { email, acceptedTermsAndConditionsAt } }, step 2 (pages/checkout/2.vue) sends +// { data: { invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured, delivery } }. +// Payment state, totals, numbers, documents, the cart and the customer are written by the server alone. A body that sends any +// of them is rejected as a whole instead of stripped, so a client that relies on such a write fails visibly. + +/** The attributes of the address.structured-address component (src/components/address/structured-address.json). */ +const STRUCTURED_ADDRESS_FIELDS = ["givenName", "familyName", "streetAddress", "postalCode", "addressLevel2", "country"] as const; + +export type CustomerStructuredAddress = Partial>; + +/** The order fields a customer may set, each optional. */ +export type CustomerUpdate = { + email?: string; + acceptedTermsAndConditionsAt?: string; + invoiceAddress?: string; + deliveryAddress?: string; + invoiceAddressStructured?: CustomerStructuredAddress; + deliveryAddressStructured?: CustomerStructuredAddress; + delivery?: number; +}; + +/** + * errors: one message per rejected field, which names the field and never repeats its value. + * fields: the names of the rejected fields alone, for the log. + */ +export type CustomerUpdateResult = { ok: true; data: CustomerUpdate } | { ok: false; errors: string[]; fields: string[] }; + +// The longest address an SMTP path of 256 octets, angle brackets included, can carry (RFC 5321). +const MAX_EMAIL_LENGTH = 254; +const MAX_ADDRESS_LENGTH = 500; +const MAX_ADDRESS_COMPONENT_LENGTH = 200; +// Strapi's ids are Postgres integer columns: a larger number makes the query fail instead of finding nothing. +const MAX_ID = 2147483647; +// Rejected keys are chosen by the client and end up in the response and the log, so a long one is cut. +const MAX_KEY_LENGTH = 64; + +const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +const keyName = (key: string): string => (key.length > MAX_KEY_LENGTH ? `${key.slice(0, MAX_KEY_LENGTH)}…` : key); + +const isStructuredAddressField = (key: string): key is (typeof STRUCTURED_ADDRESS_FIELDS)[number] => + (STRUCTURED_ADDRESS_FIELDS as readonly string[]).includes(key); + +// An "@" with something on both sides, and no whitespace or control character: the invoice is mailed to this address. +const isEmail = (email: string): boolean => { + const at = email.lastIndexOf("@"); + return email.length <= MAX_EMAIL_LENGTH && at > 0 && at < email.length - 1 && !/[\s\u0000-\u001f\u007f]/.test(email); +}; + +const isText = (value: unknown, maxLength: number): value is string => typeof value === "string" && value.length <= maxLength; + +// The shop's form sends every component as a string (pages/checkout/2.vue, mapToStructuredAddress), but it fills the form from +// the stored address, whose unset components Strapi returns as null. Every component of the address is optional. +const isAddressComponent = (value: unknown): value is string | null => value === null || isText(value, MAX_ADDRESS_COMPONENT_LENGTH); + +/** + * Checks the body of PUT /orders/:uuid/cart. Returns the fields to write, or why the body is rejected. + * `now` is the server's time, recorded as the time the customer accepted the terms. + */ +export const parseCustomerUpdate = (body: unknown, now: Date): CustomerUpdateResult => { + const rejected: { field: string; reason: string }[] = []; + const reject = (field: string, reason: string) => { + rejected.push({ field, reason }); + }; + const data: CustomerUpdate = {}; + + const parseStructuredAddress = (field: string, value: unknown): CustomerStructuredAddress | undefined => { + if (!isObject(value)) { + reject(field, "must be an object"); + return undefined; + } + const address: CustomerStructuredAddress = {}; + let valid = true; + for (const key of Object.keys(value)) { + const component = value[key]; + if (!isStructuredAddressField(key)) { + reject(`${field}.${keyName(key)}`, "not a structured address field"); + valid = false; + } else if (isAddressComponent(component)) { + address[key] = component; + } else { + reject(`${field}.${key}`, `must be a string of at most ${MAX_ADDRESS_COMPONENT_LENGTH} characters, or null`); + valid = false; + } + } + return valid ? address : undefined; + }; + + if (!isObject(body)) { + reject("body", "must be an object of the form { data: { … } }"); + } else { + for (const key of Object.keys(body)) { + if (key !== "data") reject(keyName(key), "not accepted, the fields belong in data"); + } + + const fields = body.data; + if (fields === undefined) { + reject("data", "missing"); + } else if (!isObject(fields)) { + reject("data", "must be an object"); + } else { + for (const key of Object.keys(fields)) { + const value = fields[key]; + switch (key) { + case "email": { + const email = typeof value === "string" ? value.trim() : ""; + if (isEmail(email)) data.email = email; + else reject("data.email", `must be an email address of at most ${MAX_EMAIL_LENGTH} characters`); + break; + } + case "acceptedTermsAndConditionsAt": + // The invoice is dated by this time: it is the Ausstellungsdatum in the invoice PDF (the order service's + // defaultPdfBody) and in the invoice email (src/templates/emailTemplates.ts). The client's clock and value + // are ignored, so that no client can backdate or postdate an invoice. + if (value) data.acceptedTermsAndConditionsAt = now.toISOString(); + else reject("data.acceptedTermsAndConditionsAt", "must be sent only to accept the terms, as true or a timestamp"); + break; + case "invoiceAddress": + case "deliveryAddress": + if (isText(value, MAX_ADDRESS_LENGTH)) data[key] = value; + else reject(`data.${key}`, `must be a string of at most ${MAX_ADDRESS_LENGTH} characters`); + break; + case "invoiceAddressStructured": + case "deliveryAddressStructured": { + const address = parseStructuredAddress(`data.${key}`, value); + if (address) data[key] = address; + break; + } + case "delivery": + if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_ID) data.delivery = value; + else reject("data.delivery", "must be the id of a delivery method, a positive integer"); + break; + default: + reject(`data.${keyName(key)}`, "not a field the customer may set"); + } + } + } + } + + if (rejected.length > 0) { + return { ok: false, errors: rejected.map(({ field, reason }) => `${field}: ${reason}`), fields: rejected.map(({ field }) => field) }; + } + return { ok: true, data }; +}; diff --git a/tests/unit/customer-update.test.ts b/tests/unit/customer-update.test.ts new file mode 100644 index 0000000..9dd12af --- /dev/null +++ b/tests/unit/customer-update.test.ts @@ -0,0 +1,348 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseCustomerUpdate } from "../../src/checkout/customer-update.ts"; + +// The server's time when the update arrives. +const NOW = new Date("2026-10-08T19:12:05.000Z"); + +const parse = (body: unknown) => parseCustomerUpdate(body, NOW); + +// The names of the fields a body is rejected for, or [] if it is accepted. +const rejectedFields = (body: unknown) => { + const result = parse(body); + return result.ok ? [] : result.fields; +}; + +// What the controller receives: the JSON the shop sent, parsed by Strapi's body parser. +const wire = (body: unknown) => JSON.parse(JSON.stringify(body)); + +// The shop's checkout, libreshop/shop: useCart().update(data) sends { data } (composables/useShopApi.ts, updateOrder), and its +// server route forwards that body unchanged. +// Step 1, pages/checkout/1.vue: the email and the time the browser thinks the terms were accepted. +const STEP_1 = { data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-08T19:12:03.512Z" } }; + +// Step 2, pages/checkout/2.vue: addressToString and mapToStructuredAddress of the form, and the id of the chosen delivery method. +// Without a different delivery address, the delivery address is the invoice address. +const ERIKA = { + givenName: "Erika", + familyName: "Mustermann", + streetAddress: "Musterstraße 1", + postalCode: "70190", + addressLevel2: "Stuttgart", + country: "DE" +}; +const ERIKA_TEXT = "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart"; +const STEP_2 = { + data: { invoiceAddress: ERIKA_TEXT, deliveryAddress: ERIKA_TEXT, invoiceAddressStructured: ERIKA, deliveryAddressStructured: ERIKA, delivery: 1 } +}; + +const MAX = { + givenName: "Max", + familyName: "Mustermann", + streetAddress: "Hauptstraße 5", + postalCode: "10115", + addressLevel2: "Berlin", + country: "DE" +}; +const MAX_TEXT = "Max Mustermann\nHauptstraße 5\n10115 Berlin"; + +// Every attribute of the order that only the server writes (src/api/order/content-types/order/schema.json), the fields Strapi +// manages, and the paypalOrderId the capture writes; with values a client would send to get a paid order for less. +const SERVER_FIELDS = { + paymentAuthorised: true, + paymentStatus: "authorised", + paypalOrderId: "5O190127TN364715T", + total: 0.01, + subtotal: 0.01, + VAT: 0, + invoiceNumber: "R-1-1", + invoice: 1, + invoiceSent: false, + deliveryNote: 1, + deliveryNoteNumber: "L-1-1", + deliveryNoteSent: true, + deliveryTrackingNumber: "00340434161094042557", + emailSent: true, + cart: [{ product: 101, count: 100 }], + customer: 1, + payment: 1, + uuid: "11111111-2222-4333-8444-555555555555", + hash: "0", + date: "2026-01-01", + id: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + publishedAt: "2026-01-01T00:00:00.000Z", + createdBy: 1, + updatedBy: 1 +}; + +// What the shop sends + +test("accepts the shop's step 1 payload: the email, and the terms accepted at the server's time", (t) => { + const result = parse(wire(STEP_1)); + t.diagnostic(`output: ${JSON.stringify(result)}`); + + assert.deepEqual(result, { ok: true, data: { email: "erika@example.org", acceptedTermsAndConditionsAt: "2026-10-08T19:12:05.000Z" } }); +}); + +test("accepts the shop's step 2 payload: both addresses as text and structured, and the delivery method", (t) => { + const result = parse(wire(STEP_2)); + t.diagnostic(`output: ${JSON.stringify(result)}`); + + assert.deepEqual(result, { + ok: true, + data: { + invoiceAddress: ERIKA_TEXT, + deliveryAddress: ERIKA_TEXT, + invoiceAddressStructured: ERIKA, + deliveryAddressStructured: ERIKA, + delivery: 1 + } + }); +}); + +test("accepts the shop's step 2 payload with a different delivery address", () => { + const body = { data: { ...STEP_2.data, deliveryAddress: MAX_TEXT, deliveryAddressStructured: MAX, delivery: 2 } }; + + assert.deepEqual(parse(wire(body)), { ok: true, data: body.data }); +}); + +// What only the server writes + +for (const [field, value] of Object.entries(SERVER_FIELDS)) { + test(`rejects ${field}, which only the server writes`, () => { + assert.deepEqual(parse({ data: { [field]: value } }), { + ok: false, + errors: [`data.${field}: not a field the customer may set`], + fields: [`data.${field}`] + }); + }); +} + +test("rejects the whole update, not just the server-only fields, when they come with the shop's fields", () => { + assert.deepEqual(parse({ data: { ...STEP_1.data, paymentAuthorised: true, paymentStatus: "authorised" } }), { + ok: false, + errors: ["data.paymentAuthorised: not a field the customer may set", "data.paymentStatus: not a field the customer may set"], + fields: ["data.paymentAuthorised", "data.paymentStatus"] + }); +}); + +test("names the rejected fields but never repeats their values", () => { + const result = parse({ + data: { + email: "SECRET erika@example.org", + invoiceAddress: { SECRET: true }, + invoiceAddressStructured: { ...ERIKA, givenName: "SECRET".repeat(40) }, + invoiceNumber: "R-SECRET", + delivery: "SECRET" + } + }); + + assert.equal(result.ok, false); + assert.deepEqual(result.ok ? [] : result.fields, [ + "data.email", + "data.invoiceAddress", + "data.invoiceAddressStructured.givenName", + "data.invoiceNumber", + "data.delivery" + ]); + assert.ok(!JSON.stringify(result).includes("SECRET"), JSON.stringify(result)); +}); + +// The terms + +test("records the server's time as the time the terms were accepted, whatever the client sends", () => { + for (const sent of ["2026-10-08T19:12:03.512Z", "1999-12-31T23:00:00.000Z", "2030-01-01T00:00:00.000Z", "yes", true, 1]) { + assert.deepEqual(parse({ data: { acceptedTermsAndConditionsAt: sent } }), { + ok: true, + data: { acceptedTermsAndConditionsAt: "2026-10-08T19:12:05.000Z" } + }); + } + + assert.deepEqual(parseCustomerUpdate({ data: { acceptedTermsAndConditionsAt: true } }, new Date("2026-10-09T06:30:00.000Z")), { + ok: true, + data: { acceptedTermsAndConditionsAt: "2026-10-09T06:30:00.000Z" } + }); +}); + +test("rejects a terms acceptance without a true value", () => { + for (const sent of [false, null, "", 0]) { + assert.deepEqual(parse({ data: { acceptedTermsAndConditionsAt: sent } }), { + ok: false, + errors: ["data.acceptedTermsAndConditionsAt: must be sent only to accept the terms, as true or a timestamp"], + fields: ["data.acceptedTermsAndConditionsAt"] + }); + } +}); + +// The email + +test("accepts an email address trimmed, up to 254 characters", () => { + const longest = `${"e".repeat(242)}@example.org`; + assert.equal(longest.length, 254); + + assert.deepEqual(parse({ data: { email: " erika@example.org\n" } }), { ok: true, data: { email: "erika@example.org" } }); + assert.deepEqual(parse({ data: { email: longest } }), { ok: true, data: { email: longest } }); +}); + +test("rejects an email that is not an address of at most 254 characters", () => { + const emails = [ + "", + " ", + "erika", + "erika.example.org", + "@example.org", + "erika@", + `${"e".repeat(243)}@example.org`, + "erika mustermann@example.org", + "erika@example.org\r\nBcc: everyone@example.org", + "erika@exa\u0000mple.org", + null, + 42, + true, + ["erika@example.org"], + { address: "erika@example.org" } + ]; + for (const email of emails) { + assert.deepEqual( + parse({ data: { email } }), + { ok: false, errors: ["data.email: must be an email address of at most 254 characters"], fields: ["data.email"] }, + JSON.stringify(email) + ); + } +}); + +// The addresses + +test("accepts an invoice or delivery address of up to 500 characters", () => { + const longest = `${ERIKA_TEXT}\n${"c/o ".repeat(113)}`; + assert.equal(longest.length, 500); + + assert.deepEqual(parse({ data: { invoiceAddress: longest, deliveryAddress: longest } }), { + ok: true, + data: { invoiceAddress: longest, deliveryAddress: longest } + }); +}); + +test("rejects an invoice or delivery address that is not a string of at most 500 characters", () => { + for (const field of ["invoiceAddress", "deliveryAddress"]) { + for (const value of ["a".repeat(501), null, 42, true, [ERIKA_TEXT], { street: "Musterstraße 1" }]) { + assert.deepEqual(parse({ data: { [field]: value } }), { + ok: false, + errors: [`data.${field}: must be a string of at most 500 characters`], + fields: [`data.${field}`] + }); + } + } +}); + +test("accepts a structured address with only some components, null for a component without a value, and 200 characters", () => { + const partial = { givenName: "Erika", familyName: null, postalCode: "70190", country: "DE" }; + const longest = { ...ERIKA, streetAddress: "s".repeat(200) }; + + assert.deepEqual(parse({ data: { invoiceAddressStructured: partial } }), { ok: true, data: { invoiceAddressStructured: partial } }); + assert.deepEqual(parse({ data: { deliveryAddressStructured: longest } }), { ok: true, data: { deliveryAddressStructured: longest } }); +}); + +test("rejects a structured address with keys that are not its components, and names them", () => { + assert.deepEqual(parse({ data: { invoiceAddressStructured: { ...ERIKA, id: 7, street: "Musterstraße 1" } } }), { + ok: false, + errors: [ + "data.invoiceAddressStructured.id: not a structured address field", + "data.invoiceAddressStructured.street: not a structured address field" + ], + fields: ["data.invoiceAddressStructured.id", "data.invoiceAddressStructured.street"] + }); +}); + +test("rejects a structured address that is not an object", () => { + for (const field of ["invoiceAddressStructured", "deliveryAddressStructured"]) { + for (const value of [null, ERIKA_TEXT, 42, true, [ERIKA]]) { + assert.deepEqual(parse({ data: { [field]: value } }), { + ok: false, + errors: [`data.${field}: must be an object`], + fields: [`data.${field}`] + }); + } + } +}); + +test("rejects a structured address component that is not a string of at most 200 characters, or null", () => { + for (const value of ["s".repeat(201), 42, true, ["Erika"], { name: "Erika" }]) { + assert.deepEqual(parse({ data: { deliveryAddressStructured: { ...ERIKA, givenName: value } } }), { + ok: false, + errors: ["data.deliveryAddressStructured.givenName: must be a string of at most 200 characters, or null"], + fields: ["data.deliveryAddressStructured.givenName"] + }); + } +}); + +// The delivery method + +test("accepts the id of a delivery method", () => { + for (const delivery of [1, 2, 2147483647]) { + assert.deepEqual(parse({ data: { delivery } }), { ok: true, data: { delivery } }); + } +}); + +test("rejects a delivery that is not a positive integer", () => { + // NaN and Infinity cannot come as JSON, but the parser does not rely on that. + for (const delivery of [0, -1, 1.5, "1", null, true, [1], { id: 1 }, 2147483648, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.deepEqual(parse({ data: { delivery } }), { + ok: false, + errors: ["data.delivery: must be the id of a delivery method, a positive integer"], + fields: ["data.delivery"] + }); + } +}); + +// The body + +test("rejects a body that is not an object", () => { + for (const body of [undefined, null, "", "data", 42, true, [], [STEP_1]]) { + assert.deepEqual(parse(body), { ok: false, errors: ["body: must be an object of the form { data: { … } }"], fields: ["body"] }); + } +}); + +test("rejects a body without data, and names the fields sent outside it", () => { + assert.deepEqual(parse({}), { ok: false, errors: ["data: missing"], fields: ["data"] }); + assert.deepEqual(parse(STEP_1.data), { + ok: false, + errors: [ + "email: not accepted, the fields belong in data", + "acceptedTermsAndConditionsAt: not accepted, the fields belong in data", + "data: missing" + ], + fields: ["email", "acceptedTermsAndConditionsAt", "data"] + }); +}); + +test("rejects data that is not an object", () => { + for (const data of [null, "email=erika@example.org", 42, true, [], [STEP_1.data]]) { + assert.deepEqual(parse({ data }), { ok: false, errors: ["data: must be an object"], fields: ["data"] }); + } +}); + +test("rejects keys next to data", () => { + assert.deepEqual(rejectedFields({ ...STEP_1, meta: {}, paymentAuthorised: true }), ["meta", "paymentAuthorised"]); +}); + +test("rejects __proto__ and constructor like any other key", () => { + assert.deepEqual(rejectedFields(JSON.parse('{"__proto__": {"paymentAuthorised": true}, "data": {}}')), ["__proto__"]); + assert.deepEqual(rejectedFields(JSON.parse('{"data": {"__proto__": {"paymentAuthorised": true}, "constructor": {}}}')), [ + "data.__proto__", + "data.constructor" + ]); + assert.deepEqual(rejectedFields(JSON.parse('{"data": {"invoiceAddressStructured": {"__proto__": {"givenName": "Erika"}}}}')), [ + "data.invoiceAddressStructured.__proto__" + ]); +}); + +test("cuts a rejected key to 64 characters", () => { + assert.deepEqual(rejectedFields({ data: { ["k".repeat(10000)]: 1 } }), [`data.${"k".repeat(64)}…`]); +}); + +test("accepts an update without fields", () => { + assert.deepEqual(parse({ data: {} }), { ok: true, data: {} }); +});