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 libretech/mp#71
This commit is contained in:
2 files changed
+494
No files matched your search
@@ -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<Record<(typeof STRUCTURED_ADDRESS_FIELDS)[number], string | null>>;
|
||||||
|
|
||||||
|
/** 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<string, unknown> => 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 };
|
||||||
|
};
|
||||||
@@ -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: {} });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user