Checkout: only customer fields on order updates, verified PayPal capture, safe logging (#6)
build / test (push) Successful in 11s
build / build (push) Successful in 8m31s

Co-authored-by: Michael Czechowski <mail@librete.ch>
This commit was merged in pull request #6.
This commit is contained in:
libretech authored and Michael Czechowski committed 2026-10-15 10:23:02 +02:00
1 parent 9b0c6af8b7
commit 5002cac469
24 files changed
+2911 -313

No files matched your search

+375
View File
@@ -0,0 +1,375 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
ALREADY_PAID,
NOT_CONFIRMED,
NOT_FOUND,
NOT_MATCHING,
NOT_RECORDED,
NOT_THIS_ORDER,
ORDER_CHANGED,
PAYPAL_FAILED,
capturePayment,
captureRequestId
} from "../../src/checkout/capture-payment.ts";
import { formatLogLine } from "../../src/logging/format.ts";
const UUID = "11111111-2222-4333-8444-555555555555";
const PAYPAL_ORDER_ID = "5O190127TN364715T";
const OTHER_PAYPAL_ORDER_ID = "8MC585209K746392H";
const CAPTURE_ID = "3C679366HH908993F";
// The server's time when the capture request arrives.
const NOW = new Date("2026-10-08T19:13:05.000Z");
const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET"
// PayPal's Orders v2 JSON for the order below, see paypal-verify.test.ts. Every call returns a fresh copy to change.
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
// The row the capture reads with strapi.db.query("api::order.order").findOne({ where: { uuid } }) after a checkout stored the
// PayPal order it created: subtotal 17.90, delivery 4.95, total 22.85.
const orderRow = (overrides = {}) => ({
id: 4711,
uuid: UUID,
email: "erika@example.org",
subtotal: 17.9,
total: 22.85,
VAT: 2.86,
paymentAuthorised: false,
paymentStatus: null,
invoiceSent: false,
paypalOrderId: PAYPAL_ORDER_ID,
paypalCaptureId: null,
paymentCapturedAt: null,
...overrides
});
/**
* Fakes of the dependencies over one order row, which record every call and every log line. updateOrder writes like the
* controller's update with where { id, paypalOrderId }: only while the row still carries that PayPal order, else it answers null.
* duringCapture runs while PayPal captures, as a request that changes the order at that moment would.
*/
const setup = ({
row = orderRow(),
paypalOrder = fixture("paypal-order-approved.json"),
captured = fixture("paypal-order-captured.json"),
getError = undefined,
captureError = undefined,
updateError = undefined,
duringCapture = (row) => {}
} = {}) => {
const calls = { findOrder: [], getPayPalOrder: [], capturePayPalOrder: [], updateOrder: [] };
const logs = [];
const deps = {
findOrder: async (uuid) => {
calls.findOrder.push(uuid);
return row !== null && row.uuid === uuid ? { ...row } : null;
},
getPayPalOrder: async (id) => {
calls.getPayPalOrder.push(id);
if (getError) throw getError;
return paypalOrder;
},
capturePayPalOrder: async (id, requestId) => {
calls.capturePayPalOrder.push({ id, requestId });
duringCapture(row);
if (captureError) throw captureError;
return captured;
},
updateOrder: async (id, paypalOrderId, data) => {
calls.updateOrder.push({ id, paypalOrderId, data });
if (updateError) throw updateError;
if (row.id !== id || row.paypalOrderId !== paypalOrderId) return null;
Object.assign(row, data);
return { ...row };
},
log: {
info: (message, fields) => logs.push({ level: "info", message, fields }),
warn: (message, fields) => logs.push({ level: "warn", message, fields }),
error: (message, fields) => logs.push({ level: "error", message, fields })
}
};
return { deps, calls, logs, row };
};
const capture = (deps, { uuid = UUID, paypalOrderId = PAYPAL_ORDER_ID } = {}) => capturePayment(deps, { uuid, paypalOrderId, now: NOW });
const paypalCalls = (calls) => calls.getPayPalOrder.length + calls.capturePayPalOrder.length;
// What must never reach the log: the credentials of a failed request, and the buyer's data in PayPal's responses.
const NEVER_LOGGED = [CREDENTIALS, "Authorization", "Erika", "Mustermann", "erika", "Musterstraße", "QYR5Z8XDVJNXQ", "INSTRUMENT_DECLINED"];
/** Checks the recorded log, and each line as the console shows it (config/logger.ts formats with formatLogLine). */
const assertCleanLog = (logs) => {
const lines = logs.map(({ level, message, fields }) =>
formatLogLine({ timestamp: "2026-10-08 19:13:05.000", level, message: `app:${level[0]}:order-controller: ${message}`, ...fields })
);
for (const text of [JSON.stringify(logs), ...lines]) {
for (const value of NEVER_LOGGED) assert.ok(!text.includes(value), `the log holds ${value}: ${text}`);
}
};
// What the PayPal SDK throws for an error response: @apimatic/core's ApiError, here the SDK's CustomError, with the request, whose
// Authorization header holds the client credentials (token request) or an access token, and PayPal's error body.
const paypalApiError = (statusCode: number, message: string) =>
Object.assign(new Error(message), {
request: {
method: "POST",
url: `https://api-m.sandbox.paypal.com/v2/checkout/orders/${PAYPAL_ORDER_ID}/capture`,
headers: { Authorization: `Basic ${CREDENTIALS}`, "PayPal-Request-Id": captureRequestId(UUID, PAYPAL_ORDER_ID) }
},
statusCode,
headers: { "paypal-debug-id": "f1a2b3c4d5e6f" },
body: JSON.stringify({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "INSTRUMENT_DECLINED" }], debug_id: "f1a2b3c4d5e6f" })
});
// What axios throws when PayPal cannot be reached: the request config with its headers.
const networkError = (message = "getaddrinfo EAI_AGAIN api-m.sandbox.paypal.com") =>
Object.assign(new Error(message), {
name: "AxiosError",
code: "EAI_AGAIN",
config: { method: "post", url: "https://api-m.sandbox.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
});
// The capture
test("captures the PayPal order the checkout stored and writes exactly the payment fields", async () => {
const { deps, calls, logs } = setup();
const result = await capture(deps);
assert.equal(result.status, 200);
assert.equal(result.alreadyCaptured, false);
assert.deepEqual(calls.findOrder, [UUID]);
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
assert.deepEqual(calls.capturePayPalOrder, [{ id: PAYPAL_ORDER_ID, requestId: `capture-${UUID}-${PAYPAL_ORDER_ID}` }]);
assert.deepEqual(calls.updateOrder, [
{
id: 4711,
paypalOrderId: PAYPAL_ORDER_ID,
data: { paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID, paymentCapturedAt: "2026-10-08T19:13:05.000Z" }
}
]);
// the controller answers the updated order, sanitized
assert.equal(result.order.paymentAuthorised, true);
assert.deepEqual(logs, [{ level: "info", message: "Captured the payment", fields: { order: 4711, capture: CAPTURE_ID } }]);
assertCleanLog(logs);
});
test("sends the same PayPal-Request-Id on every attempt, so that PayPal captures once", async () => {
assert.equal(captureRequestId(UUID, PAYPAL_ORDER_ID), "capture-11111111-2222-4333-8444-555555555555-5O190127TN364715T");
// PayPal accepts at most 108 characters
assert.ok(captureRequestId(UUID, PAYPAL_ORDER_ID).length <= 108);
// The first attempt times out after PayPal may have captured. The retry repeats the id, and PayPal answers the first capture.
const first = setup({ captureError: Object.assign(networkError("timeout of 30000ms exceeded"), { code: "ECONNABORTED" }) });
assert.deepEqual(await capture(first.deps), { status: 502, message: PAYPAL_FAILED });
const retry = setup();
assert.equal((await capture(retry.deps)).status, 200);
assert.equal(first.calls.capturePayPalOrder[0].requestId, retry.calls.capturePayPalOrder[0].requestId);
});
// Paying a cheap order, capturing an expensive one
test("rejects a PayPal order the checkout did not store on the order, without asking PayPal", async () => {
// The buyer paid order A with PayPal order OTHER_PAYPAL_ORDER_ID and calls the capture of the expensive order B with it.
const { deps, calls, logs, row } = setup({ row: orderRow({ id: 4712, uuid: UUID, total: 249 }) });
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: NOT_THIS_ORDER });
assert.equal(paypalCalls(calls), 0);
assert.equal(calls.updateOrder.length, 0);
assert.equal(row.paymentAuthorised, false);
assert.deepEqual(logs, [
{
level: "warn",
message: "Rejected a capture with a PayPal order the checkout did not create for the order",
fields: { order: 4712, hasPayPalOrder: true }
}
]);
});
test("rejects every PayPal order for an order without a stored one, without asking PayPal", async () => {
// no checkout yet, an order from before the checkout stored it, or a cart changed since the checkout
for (const paypalOrderId of [null, undefined, ""]) {
const { deps, calls, logs } = setup({ row: orderRow({ paypalOrderId }) });
assert.deepEqual(await capture(deps), { status: 409, message: NOT_THIS_ORDER });
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
assert.equal(logs[0].fields.hasPayPalOrder, false);
}
// a request without a PayPal order id
const { deps, calls } = setup();
assert.deepEqual(await capture(deps, { paypalOrderId: "" }), { status: 409, message: NOT_THIS_ORDER });
assert.equal(paypalCalls(calls), 0);
});
test("does not capture a PayPal order whose amount is not the order total", async () => {
const paypalOrder = fixture("paypal-order-approved.json");
paypalOrder.purchase_units[0].amount.value = "2.29";
const { deps, calls, logs } = setup({ paypalOrder });
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
assert.deepEqual(calls.getPayPalOrder, [PAYPAL_ORDER_ID]);
assert.equal(calls.capturePayPalOrder.length, 0);
assert.equal(calls.updateOrder.length, 0);
assert.deepEqual(logs, [
{
level: "warn",
message: "Rejected a capture: the PayPal order does not match the order",
fields: { order: 4711, reason: "the PayPal order amount 2.29 is not the order total 22.85" }
}
]);
assertCleanLog(logs);
});
test("does not capture a PayPal order created for another order, in another currency or not approved", async () => {
const cases: [(paypalOrder) => void, string][] = [
[
(paypalOrder) => (paypalOrder.purchase_units[0].custom_id = "99999999-8888-4777-8666-555555555555"),
"the custom_id of the PayPal order is not the order's uuid"
],
[(paypalOrder) => (paypalOrder.purchase_units[0].amount.currency_code = "USD"), "the PayPal order currency is USD, not EUR"],
[(paypalOrder) => (paypalOrder.status = "CREATED"), "the PayPal order status is CREATED, not APPROVED"]
];
for (const [change, reason] of cases) {
const paypalOrder = fixture("paypal-order-approved.json");
change(paypalOrder);
const { deps, calls, logs } = setup({ paypalOrder });
assert.deepEqual(await capture(deps), { status: 409, message: NOT_MATCHING });
assert.equal(calls.capturePayPalOrder.length + calls.updateOrder.length, 0);
assert.equal(logs[0].fields.reason, reason);
assertCleanLog(logs);
}
});
// A paid order
test("answers alreadyCaptured for a paid order and the PayPal order it was paid with, without a call or a write", async () => {
const { deps, calls, logs } = setup({ row: orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }) });
assert.deepEqual(await capture(deps), { status: 200, alreadyCaptured: true });
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
assert.deepEqual(logs, []);
});
test("rejects another PayPal order for a paid order, without a call or a write", async () => {
const paidRows = [
orderRow({ paymentAuthorised: true, paymentStatus: "captured", paypalCaptureId: CAPTURE_ID }),
// paid before the checkout stored the PayPal order
orderRow({ paymentAuthorised: true, paymentStatus: "authorised", paypalOrderId: null })
];
for (const row of paidRows) {
const { deps, calls } = setup({ row });
assert.deepEqual(await capture(deps, { paypalOrderId: OTHER_PAYPAL_ORDER_ID }), { status: 409, message: ALREADY_PAID });
assert.equal(paypalCalls(calls) + calls.updateOrder.length, 0);
}
});
test("answers 404 for a missing order, without asking PayPal", async () => {
const { deps, calls } = setup({ row: null });
assert.deepEqual(await capture(deps), { status: 404, message: NOT_FOUND });
assert.equal(paypalCalls(calls), 0);
// No uuid: no lookup at all, since Strapi drops an undefined where condition and would find any order.
for (const uuid of ["", undefined]) {
const { deps, calls } = setup();
assert.deepEqual(await capturePayment(deps, { uuid, paypalOrderId: PAYPAL_ORDER_ID, now: NOW }), { status: 404, message: NOT_FOUND });
assert.deepEqual(calls.findOrder, []);
}
});
// PayPal fails
test("answers 502 when PayPal fails, and logs the error's name, message and status code but not its request", async () => {
const failures: [string, Error & { statusCode?: number }][] = [
["getError", paypalApiError(401, "Authentication failed due to missing authorization header, or invalid authentication credentials.")],
["getError", networkError()],
["captureError", paypalApiError(422, "The requested action could not be performed, semantically incorrect, or failed business validation.")],
["captureError", networkError()]
];
for (const [failing, error] of failures) {
const { deps, calls, logs, row } = setup({ [failing]: error });
assert.deepEqual(await capture(deps), { status: 502, message: PAYPAL_FAILED });
assert.equal(calls.capturePayPalOrder.length, failing === "getError" ? 0 : 1);
assert.equal(calls.updateOrder.length, 0);
assert.equal(row.paymentAuthorised, false);
assert.equal(logs.length, 1);
assert.equal(logs[0].level, "error");
assert.deepEqual(logs[0].fields, { order: 4711, error: { name: error.name, message: error.message, statusCode: error.statusCode } });
assertCleanLog(logs);
}
});
// After PayPal answered the capture
test("keeps the order unpaid and answers 502 when the capture does not match, logging the order and the reason only", async () => {
const pending = fixture("paypal-order-captured.json");
pending.purchase_units[0].payments.captures[0].status = "PENDING";
const lower = fixture("paypal-order-captured.json");
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
const minimal = { id: PAYPAL_ORDER_ID, status: "COMPLETED", links: [] };
const cases: [unknown, string][] = [
[pending, "the capture status is PENDING, not COMPLETED"],
[lower, "the capture amount 2.29 is not the order total 22.85"],
[minimal, "the capture response has no capture"]
];
for (const [captured, reason] of cases) {
const { deps, calls, logs, row } = setup({ captured });
assert.deepEqual(await capture(deps), { status: 502, message: NOT_CONFIRMED });
assert.equal(calls.updateOrder.length, 0);
assert.equal(row.paymentAuthorised, false);
assert.deepEqual(logs, [
{
level: "error",
message: "The PayPal capture does not match the order, which stays unpaid: check the PayPal order for a refund",
fields: { order: 4711, reason }
}
]);
assertCleanLog(logs);
}
});
test("keeps the order unpaid when its cart or address changed during the capture", async () => {
// addProduct, removeProduct and updateByUuid clear paypalOrderId in the write that changes the order
const { deps, calls, logs, row } = setup({
duringCapture: (row) => {
row.total = 249;
row.paypalOrderId = null;
}
});
assert.deepEqual(await capture(deps), { status: 409, message: ORDER_CHANGED });
assert.equal(calls.updateOrder.length, 1);
assert.equal(row.paymentAuthorised, false);
assert.deepEqual(logs, [
{
level: "error",
message: "PayPal captured the payment, but the order changed during the capture and stays unpaid: check the order for a refund",
fields: { order: 4711, capture: CAPTURE_ID }
}
]);
});
test("answers 500 when the order cannot be marked paid after the capture, and logs the capture", async () => {
const { deps, logs } = setup({ updateError: new Error("Connection terminated unexpectedly") });
assert.deepEqual(await capture(deps), { status: 500, message: NOT_RECORDED });
assert.deepEqual(logs, [
{
level: "error",
message: "PayPal captured the payment, but the order could not be marked paid: check the order",
fields: {
order: 4711,
capture: CAPTURE_ID,
error: { name: "Error", message: "Connection terminated unexpectedly", statusCode: undefined }
}
}
]);
});
+351
View File
@@ -0,0 +1,351 @@
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), among them the PayPal
// order the checkout stores and the capture it verified, and the fields Strapi manages; with values a client would send to get a
// paid order for less.
const SERVER_FIELDS = {
paymentAuthorised: true,
paymentStatus: "captured",
paypalOrderId: "5O190127TN364715T",
paypalCaptureId: "3C679366HH908993F",
paymentCapturedAt: "2026-01-01T00:00:00.000Z",
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: {} });
});
@@ -0,0 +1,88 @@
{
"id": "5O190127TN364715T",
"intent": "CAPTURE",
"status": "APPROVED",
"payment_source": {
"paypal": {
"email_address": "erika.buyer@example.org",
"account_id": "QYR5Z8XDVJNXQ",
"account_status": "VERIFIED",
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"address": {
"country_code": "DE"
}
}
},
"purchase_units": [
{
"reference_id": "11111111-2222-4333-8444-555555555555",
"amount": {
"currency_code": "EUR",
"value": "22.85",
"breakdown": {
"item_total": {
"currency_code": "EUR",
"value": "15.04"
},
"shipping": {
"currency_code": "EUR",
"value": "4.95"
},
"tax_total": {
"currency_code": "EUR",
"value": "2.86"
}
}
},
"payee": {
"email_address": "merchant@example.com",
"merchant_id": "7KNGBPH2U58GQ"
},
"custom_id": "11111111-2222-4333-8444-555555555555",
"invoice_id": "R-12-4711",
"shipping": {
"name": {
"full_name": "Erika Mustermann"
},
"address": {
"address_line_1": "Musterstraße 1",
"admin_area_2": "Stuttgart",
"postal_code": "70190",
"country_code": "DE"
}
}
}
],
"payer": {
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"email_address": "erika.buyer@example.org",
"payer_id": "QYR5Z8XDVJNXQ",
"address": {
"country_code": "DE"
}
},
"create_time": "2026-10-08T19:12:40Z",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "update",
"method": "PATCH"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T/capture",
"rel": "capture",
"method": "POST"
}
]
}
@@ -0,0 +1,131 @@
{
"id": "5O190127TN364715T",
"intent": "CAPTURE",
"status": "COMPLETED",
"payment_source": {
"paypal": {
"email_address": "erika.buyer@example.org",
"account_id": "QYR5Z8XDVJNXQ",
"account_status": "VERIFIED",
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"address": {
"country_code": "DE"
}
}
},
"purchase_units": [
{
"reference_id": "11111111-2222-4333-8444-555555555555",
"amount": {
"currency_code": "EUR",
"value": "22.85",
"breakdown": {
"item_total": {
"currency_code": "EUR",
"value": "15.04"
},
"shipping": {
"currency_code": "EUR",
"value": "4.95"
},
"tax_total": {
"currency_code": "EUR",
"value": "2.86"
}
}
},
"payee": {
"email_address": "merchant@example.com",
"merchant_id": "7KNGBPH2U58GQ"
},
"custom_id": "11111111-2222-4333-8444-555555555555",
"invoice_id": "R-12-4711",
"shipping": {
"name": {
"full_name": "Erika Mustermann"
},
"address": {
"address_line_1": "Musterstraße 1",
"admin_area_2": "Stuttgart",
"postal_code": "70190",
"country_code": "DE"
}
},
"payments": {
"captures": [
{
"id": "3C679366HH908993F",
"status": "COMPLETED",
"amount": {
"currency_code": "EUR",
"value": "22.85"
},
"final_capture": true,
"seller_protection": {
"status": "ELIGIBLE",
"dispute_categories": ["ITEM_NOT_RECEIVED", "UNAUTHORIZED_TRANSACTION"]
},
"seller_receivable_breakdown": {
"gross_amount": {
"currency_code": "EUR",
"value": "22.85"
},
"paypal_fee": {
"currency_code": "EUR",
"value": "0.92"
},
"net_amount": {
"currency_code": "EUR",
"value": "21.93"
}
},
"custom_id": "11111111-2222-4333-8444-555555555555",
"invoice_id": "R-12-4711",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/3C679366HH908993F/refund",
"rel": "refund",
"method": "POST"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "up",
"method": "GET"
}
],
"create_time": "2026-10-08T19:13:02Z",
"update_time": "2026-10-08T19:13:02Z"
}
]
}
}
],
"payer": {
"name": {
"given_name": "Erika",
"surname": "Mustermann"
},
"email_address": "erika.buyer@example.org",
"payer_id": "QYR5Z8XDVJNXQ",
"address": {
"country_code": "DE"
}
},
"create_time": "2026-10-08T19:12:40Z",
"update_time": "2026-10-08T19:13:02Z",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/5O190127TN364715T",
"rel": "self",
"method": "GET"
}
]
}
+67
View File
@@ -0,0 +1,67 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { describeError } from "../../src/logging/error.ts";
import { formatLogLine } from "../../src/logging/format.ts";
const TIMESTAMP = "2026-10-08 21:29:40.426";
const CREDENTIALS = "RFVNTVlfQ0xJRU5UX0lEOkRVTU1ZX0NMSUVOVF9TRUNSRVQ="; // base64 of the dummy "DUMMY_CLIENT_ID:DUMMY_CLIENT_SECRET"
// What axios throws when PayPal cannot be reached: the request config with its headers.
const networkError = () =>
Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
name: "AxiosError",
code: "EAI_AGAIN",
config: { method: "post", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
});
test("keeps the name, message and status code of a PayPal SDK error, not its request, headers or body", () => {
// @apimatic/core's ApiError, which the SDK's CustomError and OAuthProviderError extend
const error = Object.assign(new Error("OAuth 2 Authorization endpoint returned error."), {
request: { method: "POST", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } },
statusCode: 401,
headers: { "paypal-debug-id": "f1a2b3c4d5e6f" },
body: '{"error":"invalid_client","error_description":"Client Authentication failed"}'
});
assert.deepEqual(describeError(error), { name: "Error", message: "OAuth 2 Authorization endpoint returned error.", statusCode: 401 });
});
test("keeps the name and message of an axios network error, not its config", () => {
assert.deepEqual(describeError(networkError()), { name: "AxiosError", message: "getaddrinfo EAI_AGAIN api-m.paypal.com", statusCode: undefined });
});
test("keeps the first line of a message: the SDK's response validation error quotes the response after it", () => {
const error = new Error(
'The response did not match the response schema.\n\n> Issue #1\n\nExpected value to be of type \'string\'.\n\nGiven value: {"email_address":"erika.buyer@example.org"}'
);
assert.deepEqual(describeError(error), { name: "Error", message: "The response did not match the response schema.", statusCode: undefined });
});
test("cuts a long message", () => {
assert.equal(describeError(new Error("x".repeat(500))).message, `${"x".repeat(200)}…`);
});
test("describes a thrown value that is not an error by its type alone", () => {
assert.deepEqual(describeError("/result/error"), { name: "string", message: "", statusCode: undefined });
assert.deepEqual(describeError(undefined), { name: "undefined", message: "", statusCode: undefined });
assert.deepEqual(describeError(null), { name: "object", message: "", statusCode: undefined });
assert.deepEqual(describeError({ statusCode: "500" }), { name: "object", message: "", statusCode: undefined });
});
test("the log line of the description holds no credentials", () => {
// The formatter now also keeps them out of the line of the error object itself, see log-format.test.ts.
const line = formatLogLine({
timestamp: TIMESTAMP,
level: "error",
message: "app:e:order-controller: PayPal did not capture the payment",
order: 4711,
error: describeError(networkError())
});
assert.equal(
line,
'2026-10-08 21:29:40.426Z error: app:e:order-controller: PayPal did not capture the payment {\n "order": 4711,\n "error": {\n "name": "AxiosError",\n "message": "getaddrinfo EAI_AGAIN api-m.paypal.com"\n }\n}'
);
assert.ok(!line.includes(CREDENTIALS));
});
+130 -37
View File
@@ -33,48 +33,141 @@ test("ignores the symbol keys winston adds", () => {
assert.equal(formatLogLine(info), "2026-10-08 21:29:40.426Z info: hello ");
});
// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67.
test(
"does not throw on a circular reference",
{ todo: "JSON.stringify throws on a circular reference — https://git.librete.ch/libretech/mp/issues/67" },
() => {
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
request.self = request;
test("does not throw on a circular reference", () => {
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
request.self = request;
assert.doesNotThrow(() => formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request }));
assert.doesNotThrow(() => formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request }));
});
test("does not write the Authorization header of an axios-like error into the line", () => {
const error = Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
name: "AxiosError",
code: "EAI_AGAIN",
config: { method: "post", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
});
// What strapi.log.error(error) hands this formatter: Strapi's logErrors format spreads the error and appends its stack.
const line = formatLogLine({ ...error, message: `${error.message}\n${error.stack}`, level: "error", timestamp: TIMESTAMP });
assert.ok(!line.includes(CREDENTIALS), line);
});
test("renders an Error with its message instead of {}", () => {
const error = new Error("getaddrinfo EAI_AGAIN api-m.paypal.com");
// strapi.log.error("…", { error }), as in the order controller's capturePayment
const line = formatLogLine({
timestamp: TIMESTAMP,
level: "error",
message: "app:e:order-controller: Error capturing payment for order x",
error
});
assert.ok(line.includes("getaddrinfo EAI_AGAIN api-m.paypal.com"), line);
});
// Safe serialisation.
const fieldsOf = (line: string) => JSON.parse(line.slice(line.indexOf("{")));
test("redacts the fields named like a credential at any depth, whatever their case", () => {
const line = formatLogLine({
timestamp: TIMESTAMP,
level: "debug",
message: "app:d:test",
token: "A21AAF…",
request: {
method: "POST",
headers: { Authorization: `Basic ${CREDENTIALS}`, Cookie: "koa.sess=abc", "X-API-Key": "k1", "Content-Type": "application/json" },
body: { grant_type: "client_credentials", client_secret: "s3cr3t", user: { password: "hunter2", api_key: "k2", apikey: "k3" } },
responses: [{ headers: { "set-cookie": ["koa.sess=def"] }, data: { access_token: "A21AAG…", expires_in: 32400 } }]
}
});
assert.deepEqual(fieldsOf(line), {
token: "[redacted]",
request: {
method: "POST",
headers: { Authorization: "[redacted]", Cookie: "[redacted]", "X-API-Key": "[redacted]", "Content-Type": "application/json" },
body: {
grant_type: "client_credentials",
client_secret: "[redacted]",
user: { password: "[redacted]", api_key: "[redacted]", apikey: "[redacted]" }
},
responses: [{ headers: { "set-cookie": "[redacted]" }, data: { access_token: "[redacted]", expires_in: 32400 } }]
}
});
});
test("writes the other fields of an object that contains itself, and the reference to it as [Circular]", () => {
const request: Record<string, unknown> = { method: "POST", path: "/v1/oauth2/token" };
request.socket = { request, remoteAddress: "151.101.1.21" };
const line = formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", request, list: [request] });
assert.deepEqual(fieldsOf(line), {
request: { method: "POST", path: "/v1/oauth2/token", socket: { request: "[Circular]", remoteAddress: "151.101.1.21" } },
list: [{ method: "POST", path: "/v1/oauth2/token", socket: { request: "[Circular]", remoteAddress: "151.101.1.21" } }]
});
});
test("writes an object that is referenced twice, but not by itself, both times", () => {
const delivery = { id: 1, price: 4.95 };
assert.deepEqual(fieldsOf(formatLogLine({ timestamp: TIMESTAMP, level: "info", message: "m", before: delivery, after: delivery })), {
before: { id: 1, price: 4.95 },
after: { id: 1, price: 4.95 }
});
});
test("writes an Error as its name and the first line of its message, and its stack only when asked", () => {
const error = Object.assign(new Error('The response did not match the response schema.\n\nGiven value: {"email_address":"erika@example.org"}'), {
request: { url: "https://api-m.paypal.com/v2/checkout/orders", headers: { Authorization: "Bearer A21AAF…" } },
statusCode: 201
});
const info = { timestamp: TIMESTAMP, level: "error", message: "app:e:paypal-api", error };
assert.deepEqual(fieldsOf(formatLogLine(info)), { error: { name: "Error", message: "The response did not match the response schema." } });
const withStack = fieldsOf(formatLogLine(info, { stack: true }));
assert.equal(withStack.error.stack, error.stack);
assert.deepEqual(Object.keys(withStack.error), ["name", "message", "stack"]);
});
test("writes an instance of a class without toJSON as its class name, so the headers a request object keeps stay out", () => {
class ClientRequest {
_header = `POST /v1/oauth2/token HTTP/1.1\r\nAuthorization: Basic ${CREDENTIALS}\r\n\r\n`;
method = "POST";
}
);
const line = formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "m", request: new ClientRequest(), seen: new Set([1]) });
test(
"does not write the Authorization header of an axios-like error into the line",
{ todo: "the PayPal client credentials are logged — https://git.librete.ch/libretech/mp/issues/67" },
() => {
const error = Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), {
name: "AxiosError",
code: "EAI_AGAIN",
config: { method: "post", url: "https://api-m.paypal.com/v1/oauth2/token", headers: { Authorization: `Basic ${CREDENTIALS}` } }
});
// What strapi.log.error(error) hands this formatter: Strapi's logErrors format spreads the error and appends its stack.
const line = formatLogLine({ ...error, message: `${error.message}\n${error.stack}`, level: "error", timestamp: TIMESTAMP });
assert.deepEqual(fieldsOf(line), { request: "[ClientRequest]", seen: "[Set]" });
assert.ok(!line.includes(CREDENTIALS), line);
});
assert.ok(!line.includes(CREDENTIALS), line);
test("writes an instance of a class with toJSON as what toJSON returns, redacted", () => {
// Like axios's AxiosHeaders, which an axios error carries in config.headers.
class AxiosHeaders {
toJSON() {
return { Accept: "application/json", Authorization: `Basic ${CREDENTIALS}` };
}
}
);
const at = new Date("2026-10-08T19:13:05.000Z");
const line = formatLogLine({ timestamp: TIMESTAMP, level: "error", message: "m", config: { headers: new AxiosHeaders() }, at });
test(
"renders an Error with its message instead of {}",
{ todo: "an Error is rendered as {} — https://git.librete.ch/libretech/mp/issues/67" },
() => {
const error = new Error("getaddrinfo EAI_AGAIN api-m.paypal.com");
// strapi.log.error("…", { error }), as in the order controller's capturePayment
const line = formatLogLine({
timestamp: TIMESTAMP,
level: "error",
message: "app:e:order-controller: Error capturing payment for order x",
error
});
assert.deepEqual(fieldsOf(line), {
config: { headers: { Accept: "application/json", Authorization: "[redacted]" } },
at: "2026-10-08T19:13:05.000Z"
});
});
assert.ok(line.includes("getaddrinfo EAI_AGAIN api-m.paypal.com"), line);
}
);
test("writes a bigint as its digits and a field that cannot be read as [unreadable]", () => {
const unreadable = {
get value() {
throw new Error("no");
}
};
// The PayPal SDK keeps an OAuth token's expiry as a bigint, which JSON.stringify throws on.
const line = formatLogLine({ timestamp: TIMESTAMP, level: "debug", message: "m", expiry: 1791587585n, unreadable });
assert.deepEqual(fieldsOf(line), { expiry: "1791587585", unreadable: "[unreadable]" });
});
+56
View File
@@ -0,0 +1,56 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { isLockedForCustomer, missingCheckoutFields } from "../../src/checkout/order-state.ts";
// Shaped like the row PUT /orders/:uuid/cart reads with strapi.db.query("api::order.order").findOne before it updates.
const order = (overrides = {}) => ({
id: 4711,
uuid: "11111111-2222-4333-8444-555555555555",
email: "erika@example.org",
paymentAuthorised: false,
paymentStatus: null,
invoiceSent: false,
...overrides
});
test("an order is open for the customer until its payment is authorised", () => {
assert.equal(isLockedForCustomer(order()), false);
// false is the schema's default, but a row can hold null
assert.equal(isLockedForCustomer(order({ paymentAuthorised: null })), false);
assert.equal(isLockedForCustomer({}), false);
});
test("an order is locked once its payment is authorised", () => {
// what capturePayment writes once the capture is verified (src/checkout/capture-payment.ts)
assert.equal(isLockedForCustomer(order({ paymentAuthorised: true, paymentStatus: "captured" })), true);
// what it wrote before
assert.equal(isLockedForCustomer(order({ paymentAuthorised: true, paymentStatus: "authorised" })), true);
});
// Shaped like the row POST /orders/:uuid/checkout reads (strapi.db.query findOne with orderDefaultParams), after both checkout steps.
const readyForCheckout = (overrides = {}) =>
order({
invoiceAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
delivery: { id: 1, name: "DHL", price: 4.95 },
...overrides
});
test("an order that went through both checkout steps lacks nothing for the checkout", () => {
assert.deepEqual(missingCheckoutFields(readyForCheckout()), []);
});
test("names the email, invoice address and delivery method an order still lacks for the checkout", () => {
// a new order: POST /orders creates it empty
assert.deepEqual(missingCheckoutFields({ email: null, invoiceAddress: null, delivery: null }), ["email", "invoiceAddress", "delivery"]);
assert.deepEqual(missingCheckoutFields({}), ["email", "invoiceAddress", "delivery"]);
// after step 1 alone
assert.deepEqual(missingCheckoutFields(readyForCheckout({ invoiceAddress: null, deliveryAddress: null, delivery: null })), [
"invoiceAddress",
"delivery"
]);
assert.deepEqual(missingCheckoutFields(readyForCheckout({ email: " " })), ["email"]);
assert.deepEqual(missingCheckoutFields(readyForCheckout({ invoiceAddress: "" })), ["invoiceAddress"]);
// a delivery method that was deleted is populated as null
assert.deepEqual(missingCheckoutFields(readyForCheckout({ delivery: null })), ["delivery"]);
});
+171
View File
@@ -0,0 +1,171 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { PAYPAL_TIMEOUT_MS, breaksClient, createPayPalClient, paypalClientConfiguration } from "../../src/checkout/paypal-client.ts";
const TOKEN_URL = "https://api-m.paypal.com/v1/oauth2/token";
const ORDERS_URL = "https://api-m.paypal.com/v2/checkout/orders";
const PAYPAL_ORDER = '{"id":"5O190127TN364715T","status":"CREATED"}';
// What axios throws when PayPal cannot be reached: no status code, the request in config.
const networkError = (url = TOKEN_URL) =>
Object.assign(new Error("getaddrinfo EAI_AGAIN api-m.paypal.com"), { name: "AxiosError", code: "EAI_AGAIN", config: { method: "post", url } });
// What the SDK throws when PayPal answers with an error status (@apimatic/core's ApiError): the status code and the request.
const apiError = (statusCode: number, url = ORDERS_URL) =>
Object.assign(new Error(`PayPal answered ${statusCode}`), { statusCode, request: { method: "POST", url }, headers: {}, body: "{}" });
/**
* A fake of the SDK's OrdersController over a Client. Like the SDK, every client requests its OAuth token on its first call and
* keeps the promise for its lifetime, also when the token request failed: every later call fails with that same error and
* requests nothing. The token request fails while dns.up is false. Each construction and token request is counted.
*/
const fakeSdk = () => {
const sdk = { dnsUp: false, constructions: 0, tokenRequests: 0, configurations: [] as unknown[] };
const createController = (configuration: unknown) => {
sdk.constructions += 1;
sdk.configurations.push(configuration);
let token: Promise<string> | undefined;
const authenticate = () => {
if (token === undefined) {
sdk.tokenRequests += 1;
token = sdk.dnsUp ? Promise.resolve("A21AAF…") : Promise.reject(networkError());
token.catch(() => {});
}
return token;
};
return {
ordersCreate: async () => {
await authenticate();
return { statusCode: 201, body: PAYPAL_ORDER };
}
};
};
return { sdk, createController };
};
test("a client of the SDK keeps a failed token request: every later call fails without a request", async () => {
// What the replacement below works around, as the harness's scenario S7 observed with the real SDK.
const { sdk, createController } = fakeSdk();
const orders = createController({});
const first = await orders.ordersCreate().catch((error) => error);
sdk.dnsUp = true;
const second = await orders.ordersCreate().catch((error) => error);
assert.equal(second, first);
assert.equal(sdk.tokenRequests, 1);
});
test("replaces the client after a failed token request, so that the next call requests a new token", async () => {
const { sdk, createController } = fakeSdk();
const replaced: unknown[] = [];
const client = createPayPalClient({ configuration: {}, createController, onReplace: (error) => replaced.push(error) });
await assert.rejects(
client.call((orders) => orders.ordersCreate()),
{ name: "AxiosError", message: "getaddrinfo EAI_AGAIN api-m.paypal.com" }
);
assert.deepEqual([sdk.constructions, sdk.tokenRequests, replaced.length], [1, 1, 1]);
sdk.dnsUp = true;
assert.deepEqual(await client.call((orders) => orders.ordersCreate()), { statusCode: 201, body: PAYPAL_ORDER });
assert.deepEqual([sdk.constructions, sdk.tokenRequests], [2, 2]);
// The new client keeps its token.
await client.call((orders) => orders.ordersCreate());
assert.deepEqual([sdk.constructions, sdk.tokenRequests, replaced.length], [2, 2, 1]);
});
test("keeps the client after an answer of PayPal that leaves it usable", async () => {
let constructions = 0;
const client = createPayPalClient({
configuration: {},
createController: () => {
constructions += 1;
return { ordersCreate: async () => Promise.reject(apiError(422)) };
}
});
for (let call = 0; call < 3; call++) {
const error = await client.call((orders) => orders.ordersCreate()).catch((rejection) => rejection);
assert.equal(error.statusCode, 422);
}
assert.equal(constructions, 1);
});
test("a call that fails on a replaced client does not replace its successor", async () => {
let constructions = 0;
const pending: ((error: unknown) => void)[] = [];
const replaced: unknown[] = [];
const client = createPayPalClient({
configuration: {},
createController: () => {
constructions += 1;
return { ordersGet: () => new Promise<never>((_, reject) => pending.push(reject)) };
},
onReplace: (error) => replaced.push(error)
});
// Two calls run on the first client. The first one fails, which replaces the client, and a third call starts on the second.
const first = client.call((orders) => orders.ordersGet());
const second = client.call((orders) => orders.ordersGet());
pending[0](networkError(ORDERS_URL));
await assert.rejects(first);
const third = client.call((orders) => orders.ordersGet());
assert.equal(constructions, 2);
// The second call fails on the first client only now: the second client stays.
pending[1](networkError(ORDERS_URL));
await assert.rejects(second);
const fourth = client.call((orders) => orders.ordersGet());
assert.deepEqual([constructions, replaced.length], [2, 1]);
pending[2](apiError(404));
pending[3](apiError(404));
await Promise.all([assert.rejects(third), assert.rejects(fourth)]);
});
test("breaks the client with an error of the token request, without an answer, with 401 and with 5xx", () => {
for (const error of [
networkError(TOKEN_URL),
networkError(ORDERS_URL),
Object.assign(new Error("timeout of 15000ms exceeded"), { name: "AxiosError", code: "ECONNABORTED", config: { url: ORDERS_URL } }),
apiError(400, TOKEN_URL),
apiError(429, TOKEN_URL),
apiError(401, TOKEN_URL),
apiError(401, ORDERS_URL),
apiError(500, ORDERS_URL),
apiError(503, ORDERS_URL),
// The SDK's ResponseValidationError carries the status of the answer it could not read.
Object.assign(new Error("The response did not match the response schema."), { statusCode: 200, request: { url: TOKEN_URL } }),
new Error("The HTTP call was aborted."),
"a string",
undefined
]) {
assert.equal(breaksClient(error), true, String(error?.message ?? error));
}
for (const error of [apiError(400), apiError(404), apiError(409), apiError(422), apiError(429)]) {
assert.equal(breaksClient(error), false, error.message);
}
});
test("configures the SDK client with a timeout of 15 s and without logging bodies or headers", async () => {
const configuration = paypalClientConfiguration({ clientId: "DUMMY_CLIENT_ID", clientSecret: "DUMMY_CLIENT_SECRET", environment: "Production" });
assert.equal(PAYPAL_TIMEOUT_MS, 15000);
assert.deepEqual(configuration, {
clientCredentialsAuthCredentials: { oAuthClientId: "DUMMY_CLIENT_ID", oAuthClientSecret: "DUMMY_CLIENT_SECRET" },
timeout: 15000,
environment: "Production",
logging: { logRequest: { logBody: false, logHeaders: false }, logResponse: { logBody: false, logHeaders: false } }
});
// Every client the factory creates, also after a replacement, gets this configuration.
const { sdk, createController } = fakeSdk();
const client = createPayPalClient({ configuration, createController });
await client.call((orders) => orders.ordersCreate()).catch(() => {});
sdk.dnsUp = true;
await client.call((orders) => orders.ordersCreate());
assert.equal(sdk.constructions, 2);
for (const passed of sdk.configurations) assert.equal((passed as { timeout: number }).timeout, 15000);
});
+246 -73
View File
@@ -1,6 +1,12 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildPayPalOrderItems, buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts";
import {
CheckoutNotReadyError,
ProductUnavailableError,
PRODUCT_UNAVAILABLE,
buildPayPalOrderRequest,
checkoutFailure
} from "../../src/checkout/paypal-order.ts";
import { calculateTotalProductPrice } from "../../src/checkout/pricing.ts";
import { vatDecimal, vatIncludedDecimal } from "../../src/checkout/vat.ts";
@@ -27,7 +33,7 @@ const validOrder = (overrides = {}) => ({
});
// A cart of [price, count] lines with the totals the order service computes in update() (src/api/order/services/order.ts:151-173).
const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
const orderFor = (lines: [number, number][], deliveryPrice: number | null, paymentPrice: number | null = null) => {
const cart = lines.map(([price, count], index) => ({
id: index + 1,
count,
@@ -37,17 +43,15 @@ const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
return validOrder({
cart,
subtotal,
total: Math.round((subtotal + (deliveryPrice ?? 0)) * 100) / 100,
total: Math.round((subtotal + (deliveryPrice ?? 0) + (paymentPrice ?? 0)) * 100) / 100,
VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100,
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice }
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice },
payment: paymentPrice === null ? null : { id: 1, name: "Vorkasse", price: paymentPrice }
});
};
// Both halves in the order createSessionOrThrow runs them: what it would send to PayPal, or what it would throw.
const buildSession = (order) => {
buildPayPalOrderItems(order);
return buildPayPalOrderRequest(RETURN_URL, order);
};
// What createSessionOrThrow would send to PayPal, or what it would throw before asking PayPal.
const buildSession = (order) => buildPayPalOrderRequest(RETURN_URL, order);
const cents = (money) => (money ? Math.round(Number(money.value) * 100) : 0);
@@ -71,6 +75,15 @@ const assertNoTypeError = (build: () => unknown) => {
}
};
const thrown = (build: () => unknown): unknown => {
try {
build();
} catch (error) {
return error;
}
assert.fail("did not throw");
};
// Characterization: what the builders do today.
test("builds the PayPal order request for a valid order", () => {
@@ -117,85 +130,61 @@ test("sends no shipping amount when the order has no delivery method", () => {
});
});
test("rejects an order without a total before computing the items", () => {
for (const total of [0, null, undefined]) {
assert.throws(() => buildPayPalOrderItems(validOrder({ total })), { name: "Error", message: "Cart is empty or has no total" });
// The request used to be built after a list of items that was never sent; computing it threw "Cart is empty or has no total".
// That check now throws CheckoutNotReadyError, which the checkout answers 400 instead of 502.
test("rejects an empty cart or an order without a total as not ready for checkout", () => {
for (const overrides of [{ total: 0 }, { total: null }, { total: undefined }, { cart: [] }, { cart: null }]) {
assert.throws(() => buildSession(validOrder(overrides)), { name: "CheckoutNotReadyError", message: "Cart is empty or has no total" });
}
});
test("computes one item per cart line, which is not sent to PayPal", () => {
const order = validOrder({
cart: [
{ id: 1, count: 1, product: notebook },
{ id: 2, count: 3, product: { id: 102, name: "Journaling Booklet", cover: { price: 12.5 } } }
]
});
test("sends no items: amounts rounded per unit would miss the net and the VAT of the subtotal", () => {
// 3 × 12.50: per unit 10.50 net and 2.00 tax, 31.50 and 6.00 for the three; the subtotal of 37.50 has 31.51 net and 5.99 VAT.
const unitNetCents = Math.round((1250 * 100) / 119);
const unit = buildSession(orderFor([[12.5, 3]], 4.95)).purchaseUnits[0];
assert.deepEqual(buildPayPalOrderItems(order), [
{
name: "Notizbuch A5 Punktraster",
unitAmount: { currencyCode: "EUR", value: "15.04" },
tax: { currencyCode: "EUR", value: "2.86" },
quantity: "1"
},
{
name: "Journaling Booklet",
unitAmount: { currencyCode: "EUR", value: "10.50" },
tax: { currencyCode: "EUR", value: "2.00" },
quantity: "3"
assert.equal("items" in unit, false);
assert.deepEqual([3 * unitNetCents, 3 * (1250 - unitNetCents)], [3150, 600]);
assert.deepEqual(unit.amount, {
currencyCode: "EUR",
value: "42.45",
breakdown: {
itemTotal: { currencyCode: "EUR", value: "31.51" },
taxTotal: { currencyCode: "EUR", value: "5.99" },
shipping: { currencyCode: "EUR", value: "4.95" }
}
]);
});
});
// Known defects: these assert the correct behaviour and fail today, which the todo marks as expected.
// Fixed defects of https://git.librete.ch/libretech/mp/issues/67.
test(
"D1: a missing delivery address is rejected with a clear validation error",
{ todo: "null deliveryAddress throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
() => {
assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
}
);
test("D1: a missing delivery address is rejected with a clear validation error", () => {
assert.throws(() => buildSession(validOrder({ deliveryAddress: null })), isClearError(/address/i));
});
test(
"D2: a two-line delivery address does not throw a TypeError",
{ todo: "a two-line address throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
() => {
assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
}
);
test("D2: a two-line delivery address does not throw a TypeError", () => {
assertNoTypeError(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })));
});
test(
"D3: a pickup whose delivery price is null is sent with shipping 0.00",
{ todo: "delivery price null throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
() => {
const order = validOrder({ delivery: { id: 2, name: "Abholung", price: null }, total: 17.9 });
test("D3: a pickup whose delivery price is null is sent with shipping 0.00", () => {
const order = validOrder({ delivery: { id: 2, name: "Abholung", price: null }, total: 17.9 });
assert.deepEqual(buildSession(order).purchaseUnits[0].amount.breakdown.shipping, { currencyCode: "EUR", value: "0.00" });
}
);
assert.deepEqual(buildSession(order).purchaseUnits[0].amount.breakdown.shipping, { currencyCode: "EUR", value: "0.00" });
});
test(
"D4: a cart line whose product was deleted is rejected with a clear error",
{ todo: "a deleted product throws a TypeError — https://git.librete.ch/libretech/mp/issues/67" },
() => {
assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
}
);
test("D4: a cart line whose product was deleted is rejected with a clear error", () => {
assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
});
test(
"D5: with a payment surcharge the breakdown still adds up to the amount",
{ todo: "the surcharge is in the amount but not in the breakdown — https://git.librete.ch/libretech/mp/issues/67" },
() => {
// total = 17.90 + 4.95 shipping + 1.50 surcharge
const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0];
test("D5: with a payment surcharge the breakdown still adds up to the amount", () => {
// total = 17.90 + 4.95 shipping + 1.50 surcharge
const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0];
assert.equal(amount.value, "24.35");
assert.equal(breakdownCents(amount.breakdown), cents(amount));
}
);
assert.equal(amount.value, "24.35");
assert.equal(breakdownCents(amount.breakdown), cents(amount));
});
// Not a todo: this holds today for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above.
// This held before the fixes too, for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above.
test("D6: the breakdown adds up to the amount to the cent, also for the subtotals whose net is closest to half a cent", () => {
const carts: { lines: [number, number][]; delivery: number | null }[] = [
{ lines: [[17.9, 1]], delivery: 4.95 },
@@ -232,3 +221,187 @@ test("D6: the breakdown adds up to the amount to the cent, also for the subtotal
// Compare the count, not the array: diffing tens of thousands of entries against [] takes many GB of memory.
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${JSON.stringify(mismatches.slice(0, 3))}`);
});
// Shipping address: the structured delivery address, else the structured invoice address, else the text delivery address.
const structured = (overrides = {}) => ({
givenName: "Erika",
familyName: "Mustermann",
streetAddress: "Musterstraße 1",
postalCode: "70190",
addressLevel2: "Stuttgart",
country: "DE",
...overrides
});
const berlin = structured({ givenName: "Max", streetAddress: "Hauptstraße 5", postalCode: "10115", addressLevel2: "Berlin" });
const berlinShipping = {
name: { fullName: "Max Mustermann" },
address: { addressLine1: "Hauptstraße 5", postalCode: "10115", adminArea2: "Berlin", countryCode: "DE" }
};
// An address.structured-address component that was saved without components: Strapi fills in the default country.
const emptyStructured = { givenName: null, familyName: null, streetAddress: null, postalCode: null, addressLevel2: null, country: "DE" };
const shippingOf = (order) => buildSession(order).purchaseUnits[0].shipping;
test("takes the shipping address from the structured delivery address before the invoice and the text address", () => {
assert.deepEqual(shippingOf(validOrder({ deliveryAddressStructured: berlin, invoiceAddressStructured: structured() })), berlinShipping);
});
test("falls back to the structured invoice address when no structured delivery address is entered", () => {
for (const deliveryAddressStructured of [null, undefined, emptyStructured, { givenName: " ", country: "DE" }]) {
assert.deepEqual(
shippingOf(validOrder({ deliveryAddressStructured, invoiceAddressStructured: berlin, deliveryAddress: null })),
berlinShipping
);
}
});
test("trims the components, names the buyer by the name parts there are and upper-cases the country, DE by default", () => {
const order = (address) => validOrder({ deliveryAddressStructured: { ...structured(), ...address } });
assert.deepEqual(shippingOf(order({ givenName: " Erika ", familyName: null, streetAddress: " Musterstraße 1 ", country: "at" })), {
name: { fullName: "Erika" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart", countryCode: "AT" }
});
for (const country of [null, undefined, ""]) {
assert.equal(shippingOf(order({ country })).address.countryCode, "DE");
}
});
test("rejects a structured address that is entered but incomplete instead of shipping to another address", () => {
const order = validOrder({ deliveryAddressStructured: structured({ streetAddress: "", postalCode: null }), invoiceAddressStructured: berlin });
assert.throws(() => buildSession(order), { name: "CheckoutNotReadyError", message: "deliveryAddressStructured lacks streetAddress, postalCode" });
assert.throws(() => buildSession(validOrder({ invoiceAddressStructured: structured({ givenName: null, familyName: "" }) })), {
name: "CheckoutNotReadyError",
message: "invoiceAddressStructured lacks givenName and familyName"
});
assert.throws(() => buildSession(validOrder({ deliveryAddressStructured: structured({ country: "Deutschland" }) })), {
name: "CheckoutNotReadyError",
message: "deliveryAddressStructured has no two-letter country code"
});
});
test("parses the text delivery address when no structured address is entered", () => {
const shippingFor = (deliveryAddress) => shippingOf(validOrder({ deliveryAddress, deliveryAddressStructured: emptyStructured }));
assert.deepEqual(shippingFor(" Erika Mustermann\r\nMusterstraße 1\r\n\r\n70190 Stuttgart-Bad Cannstatt \n"), {
name: { fullName: "Erika Mustermann" },
address: { addressLine1: "Musterstraße 1", postalCode: "70190", adminArea2: "Stuttgart-Bad Cannstatt", countryCode: "DE" }
});
// The street is the line above the postal code and city, as in a German address; the lines after the name go into addressLine2.
assert.deepEqual(shippingFor("Erika Mustermann\nc/o Muster GmbH\nHinterhaus\nMusterstraße 1\n70190 Stuttgart"), {
name: { fullName: "Erika Mustermann" },
address: {
addressLine1: "Musterstraße 1",
addressLine2: "c/o Muster GmbH, Hinterhaus",
postalCode: "70190",
adminArea2: "Stuttgart",
countryCode: "DE"
}
});
});
test("rejects a text delivery address it cannot parse, and a missing one, with a validation error", () => {
const cases: [unknown, string][] = [
["Erika Mustermann\nMusterstraße 1", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
["Erika Mustermann\n\nMusterstraße 1\n", "deliveryAddress needs the name, the street, and the postal code and city on lines of their own"],
["Erika Mustermann\nMusterstraße 1\n70190", "The last line of deliveryAddress is not the postal code and the city"],
[" \n ", "The order has no delivery address"],
["", "The order has no delivery address"],
[null, "The order has no delivery address"],
[undefined, "The order has no delivery address"]
];
for (const [deliveryAddress, message] of cases) {
assert.throws(() => buildSession(validOrder({ deliveryAddress })), { name: "CheckoutNotReadyError", message });
}
});
// Money in whole cents, and the errors the checkout answers.
test("reads decimals that arrive as strings like numbers", () => {
const order = validOrder({ subtotal: "17.90", total: "22.85", VAT: "2.86", delivery: { id: 1, name: "DHL", price: "4.95" } });
assert.deepEqual(buildSession(order), buildSession(validOrder()));
});
test("rejects totals that do not add up, as after a price change, instead of sending PayPal a breakdown it rejects", () => {
// The delivery method costs 5.95 now; the order's total was computed when it cost 4.95.
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: 5.95 } })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 595 + surcharge 0"
});
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: -4.95 }, total: 12.95 })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 1295 cents, items 1504 + VAT 286 + shipping -495 + surcharge 0"
});
for (const overrides of [{ VAT: null }, { subtotal: undefined }, { VAT: "n/a" }]) {
assert.throws(() => buildSession(validOrder(overrides)), { name: "CheckoutNotReadyError", message: "The order has no subtotal or no VAT" });
}
});
test("sends a payment surcharge as handling, and no handling for a payment method that costs nothing", () => {
const breakdownOf = (order) => buildSession(order).purchaseUnits[0].amount.breakdown;
assert.deepEqual(breakdownOf(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })), {
itemTotal: { currencyCode: "EUR", value: "15.04" },
taxTotal: { currencyCode: "EUR", value: "2.86" },
shipping: { currencyCode: "EUR", value: "4.95" },
handling: { currencyCode: "EUR", value: "1.50" }
});
for (const payment of [null, undefined, { id: 1, name: "PayPal", price: 0 }, { id: 1, name: "PayPal", price: null }]) {
assert.equal("handling" in breakdownOf(validOrder({ payment })), false);
}
// A surcharge the total lacks, as when the price of the payment method changed after the totals were computed.
assert.throws(() => buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 } })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 495 + surcharge 150"
});
});
test("adds up to the cent with a surcharge, with a pickup without a price, for every whole-cent subtotal up to 300.00", () => {
const mismatches: string[] = [];
for (let cent = 1; cent <= 30000; cent++) {
for (const [delivery, payment] of [
[4.95, 0.35],
[0, 1.5]
]) {
const order = orderFor([[cent / 100, 1]], delivery, payment);
if (delivery === 0) order.delivery = { id: 2, name: "Abholung", price: null };
const { amount } = buildSession(order).purchaseUnits[0];
if (breakdownCents(amount.breakdown) !== cents(amount) || cents(amount.breakdown.handling) !== Math.round(payment * 100)) {
mismatches.push(`${cent}/${delivery}/${payment}`);
}
}
}
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${mismatches.slice(0, 3).join(", ")}`);
});
test("rejects a deleted product next to available ones as unavailable", () => {
const order = validOrder({ cart: [{ id: 1, count: 1, product: notebook }, { id: 2, count: 1, product: null }, null] });
assert.throws(
() => buildSession(order),
(error) => error instanceof ProductUnavailableError && error.name === "ProductUnavailableError" && error.message === PRODUCT_UNAVAILABLE
);
});
test("answers an order that is not ready 400 with the reason, a deleted product 409 and any other error 502", () => {
assert.deepEqual(checkoutFailure(thrown(() => buildSession(validOrder({ deliveryAddress: null })))), {
status: 400,
message: "Order is not ready for checkout",
reason: "The order has no delivery address"
});
assert.equal(checkoutFailure(thrown(() => buildSession(validOrder({ deliveryAddress: "Erika Mustermann\nMusterstraße 1" })))).status, 400);
assert.equal(checkoutFailure(thrown(() => buildSession(validOrder({ total: 0 })))).status, 400);
assert.deepEqual(checkoutFailure(thrown(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })))), {
status: 409,
message: "A product in the cart is no longer available"
});
assert.ok(thrown(() => buildSession(validOrder({ total: 0 }))) instanceof CheckoutNotReadyError);
const sdkError = Object.assign(new Error("OAuth 2 provider says client authentication failed."), { statusCode: 401 });
for (const error of [sdkError, new TypeError("Cannot read properties of null (reading 'split')"), "/result/error", undefined]) {
assert.deepEqual(checkoutFailure(error), { status: 502, message: "Could not create the PayPal order" });
}
});
+260
View File
@@ -0,0 +1,260 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { orderTotalCents, parseCents, verifyApprovedOrder, verifyCompletedCapture } from "../../src/checkout/paypal-verify.ts";
import { buildPayPalOrderRequest } from "../../src/checkout/paypal-order.ts";
const UUID = "11111111-2222-4333-8444-555555555555";
// PayPal's Orders v2 JSON as PayPalApi parses the response body: the PayPal order once the buyer approved it
// (GET /v2/checkout/orders/:id), and the PayPal order after its capture (POST /v2/checkout/orders/:id/capture with
// Prefer: return=representation). Both belong to the order below. Every call returns a fresh copy to change.
const fixture = (name: string) => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf8"));
const approved = () => fixture("paypal-order-approved.json");
const captured = () => fixture("paypal-order-captured.json");
// The order of paypal-order.test.ts: subtotal 17.90, delivery 4.95, total 22.85.
const order = (overrides = {}) => ({
id: 4711,
uuid: UUID,
email: "erika@example.org",
subtotal: 17.9,
total: 22.85,
VAT: 2.86,
delivery: { id: 1, name: "DHL", price: 4.95 },
invoiceNumber: "R-12-4711",
cart: [
{ id: 1, count: 1, product: { id: 101, name: "Notizbuch A5 Punktraster", cover: { price: 14.9 }, pages: { price: 3 }, ruling: { price: 0 } } }
],
deliveryAddress: "Erika Mustermann\nMusterstraße 1\n70190 Stuttgart",
...overrides
});
// What identifies the buyer or the merchant in the fixtures. A reason is logged, so it repeats none of it.
const PERSONAL_DATA = [
"Erika",
"Mustermann",
"erika",
"QYR5Z8XDVJNXQ",
"Musterstraße",
"70190",
"Stuttgart",
"merchant@example.com",
"7KNGBPH2U58GQ"
];
/** The reason of a rejection, after checking that it repeats no personal data. */
const rejection = (result: { ok: boolean; reason?: string }): string => {
assert.equal(result.ok, false, "expected a rejection");
for (const value of PERSONAL_DATA) assert.ok(!result.reason.includes(value), `the reason repeats ${value}: ${result.reason}`);
return result.reason;
};
// Money
test("parses PayPal's money values into integer cents", () => {
assert.equal(parseCents("22.85"), 2285);
assert.equal(parseCents("0.01"), 1);
assert.equal(parseCents("4.9"), 490);
assert.equal(parseCents("100"), 10000);
assert.equal(parseCents("0.00"), 0);
assert.equal(parseCents("1234567.89"), 123456789);
});
test("parses nothing but a non-negative amount with at most two decimals", () => {
for (const value of ["", "22.850", "-22.85", "22,85", " 22.85", "22.85 ", "2.285e1", "22.", ".85", "0x10", "Infinity", "NaN", "12345678901.00"]) {
assert.equal(parseCents(value), null, JSON.stringify(value));
}
for (const value of [22.85, null, undefined, {}, ["22.85"]]) {
assert.equal(parseCents(value), null, String(value));
}
});
test("turns the order total into integer cents, also where its float is a hair below the cent", () => {
// 22.85 * 100 is 2284.9999999999995 and 0.29 * 100 is 28.999999999999996 in floating point
assert.equal(orderTotalCents({ total: 22.85 }), 2285);
assert.equal(orderTotalCents({ total: 0.29 }), 29);
assert.equal(orderTotalCents({ total: 17.9 + 4.95 }), 2285);
for (const total of [null, undefined, 0, -4.95, NaN, Infinity]) {
assert.equal(orderTotalCents({ total }), null, String(total));
}
});
test("the amount the checkout sends PayPal is the amount the capture accepts, for every total up to 3000.00", () => {
const mismatches: number[] = [];
for (let cents = 1; cents <= 300000; cents++) {
// The order service rounds every total to the cent; an order without a delivery method has its subtotal as its total.
const total = cents / 100;
const VAT = Math.round((total / 1.19) * 0.19 * 100) / 100;
const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order({ subtotal: total, total, VAT, delivery: null }));
const { amount } = request.purchaseUnits[0];
if (parseCents(amount.value) !== cents || orderTotalCents({ total }) !== cents) mismatches.push(total);
}
assert.equal(mismatches.length, 0, `e.g. ${mismatches.slice(0, 5).join(", ")}`);
});
test("the fixtures belong to the PayPal order the checkout requests for the order", () => {
const request = buildPayPalOrderRequest("https://shop.example/checkout/3", order()).purchaseUnits[0];
for (const paypalOrder of [approved(), captured()]) {
const unit = paypalOrder.purchase_units[0];
assert.equal(unit.custom_id, request.customId);
assert.equal(unit.amount.value, request.amount.value);
assert.equal(unit.amount.currency_code, request.amount.currencyCode);
}
});
// The PayPal order the buyer approved
test("accepts the PayPal order the buyer approved for the order", () => {
assert.deepEqual(verifyApprovedOrder(order(), approved()), { ok: true });
});
test("rejects a PayPal order that is not approved", () => {
for (const status of ["CREATED", "SAVED", "PAYER_ACTION_REQUIRED", "VOIDED", "COMPLETED"]) {
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), status })), `the PayPal order status is ${status}, not APPROVED`);
}
const { status, ...withoutStatus } = approved();
assert.equal(rejection(verifyApprovedOrder(order(), withoutStatus)), "the PayPal order status is missing or unexpected, not APPROVED");
// a value that is not one of PayPal's statuses is not repeated
assert.equal(
rejection(verifyApprovedOrder(order(), { ...approved(), status: "APPROVED BY ERIKA MUSTERMANN" })),
"the PayPal order status is missing or unexpected, not APPROVED"
);
});
test("rejects the PayPal order of a cheap order captured for an expensive one", () => {
// The buyer pays order A, then calls the capture of order B with A's PayPal order.
const orderB = order({ id: 4712, uuid: "99999999-8888-4777-8666-555555555555", total: 249 });
assert.equal(rejection(verifyApprovedOrder(orderB, approved())), "the custom_id of the PayPal order is not the order's uuid");
});
test("rejects a PayPal order without a custom_id, also for an order without a uuid", () => {
const paypalOrder = approved();
delete paypalOrder.purchase_units[0].custom_id;
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
// undefined is not compared with undefined, nor "" with ""
assert.equal(
rejection(verifyApprovedOrder(order({ uuid: undefined }), paypalOrder)),
"the custom_id of the PayPal order is not the order's uuid"
);
paypalOrder.purchase_units[0].custom_id = "";
assert.equal(rejection(verifyApprovedOrder(order({ uuid: "" }), paypalOrder)), "the custom_id of the PayPal order is not the order's uuid");
});
test("rejects a PayPal order whose amount is not the order total", () => {
const withAmount = (value: unknown) => {
const paypalOrder = approved();
paypalOrder.purchase_units[0].amount.value = value;
return paypalOrder;
};
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.84"))), "the PayPal order amount 22.84 is not the order total 22.85");
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("22.86"))), "the PayPal order amount 22.86 is not the order total 22.85");
assert.equal(rejection(verifyApprovedOrder(order(), withAmount("2285"))), "the PayPal order amount 2285.00 is not the order total 22.85");
// the cart changed after the checkout
assert.equal(rejection(verifyApprovedOrder(order({ total: 249 }), approved())), "the PayPal order amount 22.85 is not the order total 249.00");
for (const value of ["22.850", "22,85", "", 22.85, null]) {
assert.equal(rejection(verifyApprovedOrder(order(), withAmount(value))), "the PayPal order amount is not a money value", String(value));
}
});
test("rejects a PayPal order in another currency or without an amount", () => {
const paypalOrder = approved();
paypalOrder.purchase_units[0].amount.currency_code = "USD";
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is USD, not EUR");
paypalOrder.purchase_units[0].amount.currency_code = "Euro";
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order currency is missing or unexpected, not EUR");
delete paypalOrder.purchase_units[0].amount;
assert.equal(rejection(verifyApprovedOrder(order(), paypalOrder)), "the PayPal order has no amount");
});
test("rejects every PayPal order for an order without a total", () => {
for (const total of [null, 0]) {
assert.equal(rejection(verifyApprovedOrder(order({ total }), approved())), "the order has no total");
}
});
test("rejects a PayPal order without a purchase unit, and anything that is not a PayPal order", () => {
assert.equal(rejection(verifyApprovedOrder(order(), { ...approved(), purchase_units: [] })), "the PayPal order has no purchase unit");
const { purchase_units, ...withoutUnits } = approved();
assert.equal(rejection(verifyApprovedOrder(order(), withoutUnits)), "the PayPal order has no purchase unit");
// "/result/error" is what PayPalApi.checkPaymentStatus answered for every error
for (const value of [null, undefined, "/result/error", [], 42]) {
assert.equal(rejection(verifyApprovedOrder(order(), value)), "the PayPal order is not an object", String(value));
}
});
// The PayPal order after the capture
test("accepts the completed capture of the order total and returns the capture id", () => {
assert.deepEqual(verifyCompletedCapture(order(), captured()), { ok: true, captureId: "3C679366HH908993F" });
});
test("rejects a capture response whose PayPal order is not completed", () => {
for (const status of ["APPROVED", "PAYER_ACTION_REQUIRED", "VOIDED"]) {
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), status })), `the PayPal order status is ${status}, not COMPLETED`);
}
});
test("rejects a capture PayPal holds for review, declined or refunded", () => {
for (const status of ["PENDING", "DECLINED", "FAILED", "REFUNDED", "PARTIALLY_REFUNDED"]) {
const response = captured();
response.purchase_units[0].payments.captures[0].status = status;
if (status === "PENDING") response.purchase_units[0].payments.captures[0].status_details = { reason: "PENDING_REVIEW" };
assert.equal(rejection(verifyCompletedCapture(order(), response)), `the capture status is ${status}, not COMPLETED`);
}
});
test("rejects a capture response without a capture", () => {
// what PayPal answers with Prefer: return=minimal, which the capture used to ask for
const minimal = { id: "5O190127TN364715T", status: "COMPLETED", links: captured().links };
assert.equal(rejection(verifyCompletedCapture(order(), minimal)), "the capture response has no capture");
const withoutPayments = captured();
delete withoutPayments.purchase_units[0].payments;
assert.equal(rejection(verifyCompletedCapture(order(), withoutPayments)), "the capture response has no capture");
const withoutCaptures = captured();
withoutCaptures.purchase_units[0].payments.captures = [];
assert.equal(rejection(verifyCompletedCapture(order(), withoutCaptures)), "the capture response has no capture");
assert.equal(rejection(verifyCompletedCapture(order(), { ...captured(), purchase_units: [] })), "the capture response has no capture");
});
test("rejects a capture of another amount or in another currency", () => {
const lower = captured();
lower.purchase_units[0].payments.captures[0].amount.value = "2.29";
assert.equal(rejection(verifyCompletedCapture(order(), lower)), "the capture amount 2.29 is not the order total 22.85");
// the cart changed during the capture
assert.equal(rejection(verifyCompletedCapture(order({ total: 249 }), captured())), "the capture amount 22.85 is not the order total 249.00");
const usd = captured();
usd.purchase_units[0].payments.captures[0].amount.currency_code = "USD";
assert.equal(rejection(verifyCompletedCapture(order(), usd)), "the capture currency is USD, not EUR");
const withoutAmount = captured();
delete withoutAmount.purchase_units[0].payments.captures[0].amount;
assert.equal(rejection(verifyCompletedCapture(order(), withoutAmount)), "the capture has no amount");
});
test("rejects a capture without a valid id", () => {
for (const id of [undefined, "", 12345, "3C679366HH908993F; Erika Mustermann", "x".repeat(65)]) {
const response = captured();
response.purchase_units[0].payments.captures[0].id = id;
assert.equal(rejection(verifyCompletedCapture(order(), response)), "the capture has no valid id", String(id));
}
});
test("rejects anything that is not a capture response", () => {
for (const value of [null, undefined, "/result/error", [], 42]) {
assert.equal(rejection(verifyCompletedCapture(order(), value)), "the capture response is not an object", String(value));
}
});