Checkout: only customer fields on order updates, verified PayPal capture, safe logging #6
No files matched your search
@@ -0,0 +1,30 @@
|
||||
/** What is logged of an error. */
|
||||
export type ErrorSummary = {
|
||||
name: string;
|
||||
message: string;
|
||||
statusCode: number | undefined;
|
||||
};
|
||||
|
||||
// A message is cut to its first line and to this length: the PayPal SDK's ResponseValidationError quotes the response values it
|
||||
// rejected on the lines after its first.
|
||||
const MAX_LENGTH = 200;
|
||||
|
||||
const cut = (text: string): string => {
|
||||
const line = text.split("\n")[0];
|
||||
return line.length > MAX_LENGTH ? `${line.slice(0, MAX_LENGTH)}…` : line;
|
||||
};
|
||||
|
||||
/**
|
||||
* The name, the first line of the message and the HTTP status code of an error, and nothing else. An error of the PayPal SDK
|
||||
* (ApiError) or of axios carries its request, whose Authorization header holds the PayPal client credentials or an access token,
|
||||
* so the error object itself is never logged.
|
||||
*/
|
||||
export const describeError = (error: unknown): ErrorSummary => {
|
||||
if (typeof error !== "object" || error === null) return { name: typeof error, message: "", statusCode: undefined };
|
||||
const { name, message, statusCode } = error as { name?: unknown; message?: unknown; statusCode?: unknown };
|
||||
return {
|
||||
name: typeof name === "string" ? cut(name) : "object",
|
||||
message: typeof message === "string" ? cut(message) : "",
|
||||
statusCode: typeof statusCode === "number" ? statusCode : undefined
|
||||
};
|
||||
};
|
||||
@@ -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", () => {
|
||||
// Compare log-format.test.ts: the line of the error object itself holds them, a known defect of the formatter.
|
||||
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));
|
||||
});
|
||||
Reference in new issue
Block a user