import { test } from "node:test"; import assert from "node:assert/strict"; 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" // Characterization: what the console line looks like today. test("renders timestamp, level and message, with a trailing space when there are no other fields", () => { assert.equal( formatLogLine({ timestamp: TIMESTAMP, level: "info", message: "app:i:paypal-api: hello" }), "2026-10-08 21:29:40.426Z info: app:i:paypal-api: hello " ); }); test("appends the other fields as JSON indented by two spaces", () => { assert.equal( formatLogLine({ timestamp: TIMESTAMP, level: "debug", message: "app:d:order-service: Calculating totals", productsTotal: 17.9, deliveryPrice: 4.95 }), '2026-10-08 21:29:40.426Z debug: app:d:order-service: Calculating totals {\n "productsTotal": 17.9,\n "deliveryPrice": 4.95\n}' ); }); test("ignores the symbol keys winston adds", () => { const info = { timestamp: TIMESTAMP, level: "info", message: "hello", [Symbol.for("level")]: "info", [Symbol.for("splat")]: [] }; assert.equal(formatLogLine(info), "2026-10-08 21:29:40.426Z info: hello "); }); // Fixed defects of https://git.librete.ch/libretech/mp/issues/67. test("does not throw on a circular reference", () => { const request: Record = { method: "POST", path: "/v1/oauth2/token" }; request.self = 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 = { 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]) }); assert.deepEqual(fieldsOf(line), { request: "[ClientRequest]", seen: "[Set]" }); 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 }); assert.deepEqual(fieldsOf(line), { config: { headers: { Accept: "application/json", Authorization: "[redacted]" } }, at: "2026-10-08T19:13:05.000Z" }); }); 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]" }); });