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: {} }); });