diff --git a/src/logging/error.ts b/src/logging/error.ts new file mode 100644 index 0000000..0ea4ea6 --- /dev/null +++ b/src/logging/error.ts @@ -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 + }; +}; diff --git a/tests/unit/log-error.test.ts b/tests/unit/log-error.test.ts new file mode 100644 index 0000000..2407e96 --- /dev/null +++ b/tests/unit/log-error.test.ts @@ -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)); +});