diff --git a/package-lock.json b/package-lock.json index e55d061..0c1cb83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,15 +28,15 @@ }, "devDependencies": { "@apify/actor-memory-expression": "^0.1.6", - "@eslint/js": "^9.39.2", + "@eslint/js": "^10.0.1", "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.0.6", - "@types/supertest": "^6.0.3", + "@types/supertest": "^7.2.0", "eslint": "^10.0.0", - "eslint-plugin-sonarjs": "^3.0.7", + "eslint-plugin-sonarjs": "^4.0.2", "eventsource": "^4.1.0", "globals": "^17.0.0", "husky": "^9.1.7", @@ -45,28 +45,28 @@ "prettier": "^3.8.0", "supertest": "^7.2.2", "ts-api-utils": "^2.4.0", - "typescript": "^5.9.3" + "typescript": "^6.0.2" }, "engines": { "node": ">=20" } }, "node_modules/@apify/actor-memory-expression": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@apify/actor-memory-expression/-/actor-memory-expression-0.1.10.tgz", - "integrity": "sha512-5o1fhsPP8RedoU+jG5uCDWBZgnQrXYSjD97+85uUexkrNjVPjBcRiN4diXfn15QqDC2LYorYsdWRsgDS2qc6Pg==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@apify/actor-memory-expression/-/actor-memory-expression-0.1.11.tgz", + "integrity": "sha512-OgYn8CXQztgjYgVqSj2sfTaF/3YEcGX4FDchZb3wjDONTPe8F7oM6dP9bNsc/V+HU8Ijz+oJtR26XSO7e5xMvw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@apify/consts": "^2.52.0", - "@apify/log": "^2.5.34", + "@apify/consts": "^2.52.1", + "@apify/log": "^2.5.35", "mathjs": "^15.1.0" } }, "node_modules/@apify/consts": { - "version": "2.52.0", - "resolved": "https://registry.npmjs.org/@apify/consts/-/consts-2.52.0.tgz", - "integrity": "sha512-qFktl5YUPSpJBOk+MpH1MMnWmLBp1ZudpcPilbteXVBjXmr4LeX+JaKEjVtUS1VaH6RPDoKFZAU4en9TO2QKSg==", + "version": "2.52.1", + "resolved": "https://registry.npmjs.org/@apify/consts/-/consts-2.52.1.tgz", + "integrity": "sha512-Nhal8FiIgAw5ylVL4U2DAeJJyKow0bFObAX/og5BJjB9xJ2csQcyVAx4ChnO7XOaeRU8HbRn9u0QUGzPt5NNqA==", "license": "Apache-2.0" }, "node_modules/@apify/datastructures": { @@ -87,12 +87,12 @@ } }, "node_modules/@apify/log": { - "version": "2.5.34", - "resolved": "https://registry.npmjs.org/@apify/log/-/log-2.5.34.tgz", - "integrity": "sha512-H6vGSvH9lgchpaKkTMtlhF1BI+FaFOyumBox2dEH84oGJpHYVdhj3065ZpvIVcOODntrrrY9176OTURnUvgG7A==", + "version": "2.5.35", + "resolved": "https://registry.npmjs.org/@apify/log/-/log-2.5.35.tgz", + "integrity": "sha512-dJM9RkA9yD7kew5oU3qxLaoB4hFHB7FF47TI0STJVmz0cUa8cXWer4DpJkvUA52lrVNQGsOurCo3kGQWzfg/9w==", "license": "Apache-2.0", "dependencies": { - "@apify/consts": "^2.52.0", + "@apify/consts": "^2.52.1", "ansi-colors": "^4.1.1" } }, @@ -1080,16 +1080,24 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { @@ -1168,29 +1176,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2085,12 +2070,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/qs": { @@ -2158,9 +2143,9 @@ } }, "node_modules/@types/supertest": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", - "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", "dev": true, "license": "MIT", "dependencies": { @@ -3821,9 +3806,9 @@ } }, "node_modules/dotenv": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", - "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4093,38 +4078,63 @@ } }, "node_modules/eslint-plugin-sonarjs": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-3.0.7.tgz", - "integrity": "sha512-62jB20krIPvcwBLAyG3VVKa2ce2j2lL1yCb8Y0ylMRR/dLvCCTiQx8gQbXb+G81k1alPZ2/I3muZinqWQdBbzw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.0.2.tgz", + "integrity": "sha512-BTcT1zr1iTbmJtVlcesISwnXzh+9uhf9LEOr+RRNf4kR8xA0HQTPft4oiyOCzCOGKkpSJxjR8ZYF6H7VPyplyw==", "dev": true, "license": "LGPL-3.0-only", "dependencies": { - "@eslint-community/regexpp": "4.12.2", - "builtin-modules": "3.3.0", - "bytes": "3.1.2", - "functional-red-black-tree": "1.0.1", - "jsx-ast-utils-x": "0.1.0", - "lodash.merge": "4.6.2", - "minimatch": "10.1.2", - "scslre": "0.3.0", - "semver": "7.7.4", + "@eslint-community/regexpp": "^4.12.2", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.4.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.4", + "scslre": "^0.3.0", + "semver": "^7.7.4", + "ts-api-utils": "^2.4.0", "typescript": ">=5" }, "peerDependencies": { - "eslint": "^8.0.0 || ^9.0.0" + "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5066,9 +5076,9 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", "dev": true, "license": "MIT", "engines": { @@ -7626,9 +7636,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -9132,9 +9142,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9158,9 +9168,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/universalify": { diff --git a/package.json b/package.json index 5dc6798..f0ba081 100644 --- a/package.json +++ b/package.json @@ -61,22 +61,22 @@ }, "homepage": "https://github.com/ar27111994/webhook-debugger-logger#readme", "engines": { - "node": ">=20" + "node": ">=20.19.0" }, "overrides": { "basic-ftp": "5.2.2" }, "devDependencies": { "@apify/actor-memory-expression": "^0.1.6", - "@eslint/js": "^9.39.2", + "@eslint/js": "^10.0.1", "@types/compression": "^1.8.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.0.6", - "@types/supertest": "^6.0.3", + "@types/supertest": "^7.2.0", "eslint": "^10.0.0", - "eslint-plugin-sonarjs": "^3.0.7", + "eslint-plugin-sonarjs": "^4.0.2", "eventsource": "^4.1.0", "globals": "^17.0.0", "husky": "^9.1.7", @@ -85,7 +85,7 @@ "prettier": "^3.8.0", "supertest": "^7.2.2", "ts-api-utils": "^2.4.0", - "typescript": "^5.9.3" + "typescript": "^6.0.2" }, "publishConfig": { "access": "public", diff --git a/src/services/ForwardingService.js b/src/services/ForwardingService.js index 2187eb9..69fe9d4 100644 --- a/src/services/ForwardingService.js +++ b/src/services/ForwardingService.js @@ -32,6 +32,27 @@ import { CircuitBreaker } from "./CircuitBreaker.js"; const log = createChildLogger({ component: LOG_COMPONENTS.FORWARDING_SERVICE }); +/** + * Parses a Content-Length header only when it is a single, non-negative decimal value. + * Array-valued or malformed headers are ignored so callers can fall back to measuring + * the actual request body size. + * @param {string | string[] | undefined} value + * @returns {number | null} + */ +function parseStrictContentLength(value) { + if (Array.isArray(value) || typeof value !== "string") { + return null; + } + + const normalizedValue = value.trim(); + if (!/^\d+$/.test(normalizedValue)) { + return null; + } + + const parsedValue = Number(normalizedValue); + return Number.isSafeInteger(parsedValue) ? parsedValue : null; +} + /** * @typedef {import('axios').AxiosInstance} AxiosInstance * @typedef {import('axios').AxiosResponse} AxiosResponse @@ -268,20 +289,24 @@ export class ForwardingService { // 3. Defensive Body Size Check const MAX_FORWARD_BODY = APP_CONSTS.MAX_ALLOWED_PAYLOAD_SIZE; - let bodySize = 0; - if (req.headers[HTTP_HEADERS.CONTENT_LENGTH]) { - bodySize = parseInt(String(req.headers[HTTP_HEADERS.CONTENT_LENGTH]), 10); + + // Only trust a strictly decimal Content-Length header. Arrays and malformed + // values fall back to measuring the body directly. + const parsedContentLength = parseStrictContentLength( + req.headers[HTTP_HEADERS.CONTENT_LENGTH], + ); + let bodySize; + if (parsedContentLength !== null) { + bodySize = parsedContentLength; + } else if (Buffer.isBuffer(req.body)) { + bodySize = req.body.length; + } else if (typeof req.body === "string") { + bodySize = Buffer.byteLength(req.body); } else { - if (Buffer.isBuffer(req.body)) { - bodySize = req.body.length; - } else if (typeof req.body === "string") { - bodySize = Buffer.byteLength(req.body); - } else { - try { - bodySize = Buffer.byteLength(JSON.stringify(req.body)); - } catch { - bodySize = 0; - } + try { + bodySize = Buffer.byteLength(JSON.stringify(req.body)); + } catch { + bodySize = 0; } } diff --git a/tests/setup/helpers/constant-discovery.js b/tests/setup/helpers/constant-discovery.js index ae769cf..041099a 100644 --- a/tests/setup/helpers/constant-discovery.js +++ b/tests/setup/helpers/constant-discovery.js @@ -39,6 +39,7 @@ for (const entry of readdirSync(dir, { withFileTypes: true })) { } catch (error) { throw new Error( `Failed to discover constant modules at ${constsDir}: ${/** @type {Error} */ (error).message}`, + { cause: error }, ); } } diff --git a/tests/setup/helpers/e2e-process-harness.js b/tests/setup/helpers/e2e-process-harness.js index 5ec5a62..b034bc0 100644 --- a/tests/setup/helpers/e2e-process-harness.js +++ b/tests/setup/helpers/e2e-process-harness.js @@ -243,7 +243,7 @@ export async function spawnAppProcess(options) { .filter(Boolean) .join("\n\n"); - throw new Error(diagnostics); + throw new Error(diagnostics, { cause: error }); } const stop = async () => { diff --git a/tests/unit/services/forwarding_service.test.js b/tests/unit/services/forwarding_service.test.js index 3dc434e..eb0d3e5 100644 --- a/tests/unit/services/forwarding_service.test.js +++ b/tests/unit/services/forwarding_service.test.js @@ -4,10 +4,16 @@ */ import { describe, it, expect, beforeEach, jest } from "@jest/globals"; -import { useMockCleanup, useFakeTimers } from "../../setup/helpers/test-lifecycle.js"; -import { assertType, createMockRequest, flushPromises } from "../../setup/helpers/test-utils.js"; +import { + useMockCleanup, + useFakeTimers, +} from "../../setup/helpers/test-lifecycle.js"; +import { + assertType, + createMockRequest, + flushPromises, +} from "../../setup/helpers/test-utils.js"; import { setupCommonMocks } from "../../setup/helpers/mock-setup.js"; -import { HTTP_STATUS_MESSAGES, MIME_TYPES } from "../../../src/consts/http.js"; /** * @typedef {import("../../../src/services/ForwardingService.js").ForwardingService} ForwardingServiceInstance @@ -18,23 +24,36 @@ import { HTTP_STATUS_MESSAGES, MIME_TYPES } from "../../../src/consts/http.js"; */ await setupCommonMocks({ - axios: true, - apify: true, - ssrf: true, - logger: true, - consts: true + axios: true, + apify: true, + ssrf: true, + logger: true, + consts: true, }); -const { FORWARDING_CONSTS, APP_CONSTS } = await import("../../../src/consts/app.js"); -const { HTTP_STATUS, HTTP_METHODS, HTTP_HEADERS, RECURSION_HEADER_NAME, RECURSION_HEADER_VALUE } = await import("../../../src/consts/http.js"); -const { ERROR_MESSAGES, ERROR_LABELS } = await import("../../../src/consts/errors.js"); +const { FORWARDING_CONSTS, APP_CONSTS } = + await import("../../../src/consts/app.js"); +const { + HTTP_STATUS, + HTTP_METHODS, + HTTP_HEADERS, + RECURSION_HEADER_NAME, + RECURSION_HEADER_VALUE, + HTTP_STATUS_MESSAGES, + MIME_TYPES, +} = await import("../../../src/consts/http.js"); +const { ERROR_MESSAGES, ERROR_LABELS } = + await import("../../../src/consts/errors.js"); const { LOG_MESSAGES } = await import("../../../src/consts/messages.js"); -const { axiosMock, apifyMock, ssrfMock, loggerMock } = await import("../../setup/helpers/shared-mocks.js"); -const { ForwardingService } = await import("../../../src/services/ForwardingService.js"); +const { axiosMock, apifyMock, ssrfMock, loggerMock } = + await import("../../setup/helpers/shared-mocks.js"); +const { ForwardingService } = + await import("../../../src/services/ForwardingService.js"); const DEFAULT_MAX_RETRIES = 3; const SHORT_MAX_RETRIES = 2; -const CIRCUIT_BREAKER_FAILURE_COUNT = FORWARDING_CONSTS.CIRCUIT_BREAKER_FAILURE_THRESHOLD; +const CIRCUIT_BREAKER_FAILURE_COUNT = + FORWARDING_CONSTS.CIRCUIT_BREAKER_FAILURE_THRESHOLD; const MOCK_CONTENT_LENGTH = 100; const MOCK_BUFFER_SIZE = 10; const TEST_BAD_URL = "https://bad.com"; @@ -43,691 +62,1005 @@ const TEST_URL_HTTP = `http://${TEST_URL_HOST}/hook`; const TEST_URL_HTTPS = `https://${TEST_URL_HOST}/hook`; describe("ForwardingService", () => { - useMockCleanup(); - useFakeTimers(); - - /** @type {ForwardingServiceInstance} */ - let service; - /** @type {AxiosMock} */ - let mockAxiosInstance; - - beforeEach(() => { - mockAxiosInstance = axiosMock.create(); - service = new ForwardingService(); - - // Default safe SSRF - ssrfMock.validateUrlForSsrf.mockResolvedValue({ safe: true, href: TEST_URL_HTTP, host: TEST_URL_HOST }); - apifyMock.pushData.mockResolvedValue(undefined); - }); - - describe("constructor & connection pool", () => { - it("should initialize axios with a custom HTTP/HTTPS agent and strict security limits", () => { - expect(axiosMock.create).toHaveBeenCalledWith( - expect.objectContaining({ - timeout: FORWARDING_CONSTS.FORWARD_TIMEOUT_MS, - maxRedirects: 0, - validateStatus: null, - httpAgent: expect.any(Object), - httpsAgent: expect.any(Object), - }) - ); - }); + useMockCleanup(); + useFakeTimers(); + + /** @type {ForwardingServiceInstance} */ + let service; + /** @type {AxiosMock} */ + let mockAxiosInstance; + + beforeEach(() => { + mockAxiosInstance = axiosMock.create(); + service = new ForwardingService(); + + // Default safe SSRF + ssrfMock.validateUrlForSsrf.mockResolvedValue({ + safe: true, + href: TEST_URL_HTTP, + host: TEST_URL_HOST, + }); + apifyMock.pushData.mockResolvedValue(undefined); + }); + + describe("constructor & connection pool", () => { + it("should initialize axios with a custom HTTP/HTTPS agent and strict security limits", () => { + expect(axiosMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: FORWARDING_CONSTS.FORWARD_TIMEOUT_MS, + maxRedirects: 0, + validateStatus: null, + httpAgent: expect.any(Object), + httpsAgent: expect.any(Object), + }), + ); + }); + }); + + describe("sendSafeRequest()", () => { + const defaultHeaders = { + [HTTP_HEADERS.CONTENT_TYPE]: MIME_TYPES.JSON, + "x-secret": "hidden", + }; + const defaultUrl = TEST_URL_HTTPS; + + it("should send a basic request and return response on success", async () => { + const mockResponse = { + status: HTTP_STATUS.OK, + data: HTTP_STATUS_MESSAGES[HTTP_STATUS.OK], + }; + mockAxiosInstance.request.mockResolvedValue(mockResponse); + + const result = await service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + { ok: 1 }, + defaultHeaders, + {}, + ); + expect(result).toBe(mockResponse); + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: defaultUrl, + method: HTTP_METHODS.POST, + headers: expect.objectContaining({ + [RECURSION_HEADER_NAME]: RECURSION_HEADER_VALUE, + }), + }), + ); }); - describe("sendSafeRequest()", () => { - const defaultHeaders = { [HTTP_HEADERS.CONTENT_TYPE]: MIME_TYPES.JSON, "x-secret": "hidden" }; - const defaultUrl = TEST_URL_HTTPS; - - it("should send a basic request and return response on success", async () => { - const mockResponse = { status: HTTP_STATUS.OK, data: HTTP_STATUS_MESSAGES[HTTP_STATUS.OK] }; - mockAxiosInstance.request.mockResolvedValue(mockResponse); - - const result = await service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, { ok: 1 }, defaultHeaders, {}); - expect(result).toBe(mockResponse); - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); - expect(mockAxiosInstance.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: defaultUrl, - method: HTTP_METHODS.POST, - headers: expect.objectContaining({ - [RECURSION_HEADER_NAME]: RECURSION_HEADER_VALUE, - }), - }) - ); - }); - - it("should strip FORWARD_HEADERS_TO_IGNORE when forwardHeaders is true", async () => { - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - const headers = { "host": "old.com", [HTTP_HEADERS.CONTENT_LENGTH]: "100", "safe-header": "test" }; - await service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, headers, { forwardHeaders: true }); - - const reqConfig = mockAxiosInstance.request.mock.calls[0][0]; - expect(reqConfig.headers).toHaveProperty("safe-header", "test"); - expect(reqConfig.headers).not.toHaveProperty("host", "old.com"); // host is stripped - expect(reqConfig.headers).not.toHaveProperty(HTTP_HEADERS.CONTENT_LENGTH); - }); - - it("should only forward Content-Type when forwardHeaders is false", async () => { - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - const headers = { [HTTP_HEADERS.CONTENT_TYPE]: MIME_TYPES.JSON, "safe-header": "test" }; - await service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, headers, { forwardHeaders: false }); - - const reqConfig = mockAxiosInstance.request.mock.calls[0][0]; - expect(reqConfig.headers).toHaveProperty(HTTP_HEADERS.CONTENT_TYPE, MIME_TYPES.JSON); - expect(reqConfig.headers).not.toHaveProperty("safe-header"); - }); - - it("should inject a custom hostHeader override if provided", async () => { - const newHost = "new-host.com"; - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - await service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, { hostHeader: newHost }); - - const reqConfig = mockAxiosInstance.request.mock.calls[0][0]; - expect(reqConfig.headers).toHaveProperty(HTTP_HEADERS.HOST, newHost); - }); - - it("should immediately throw ERROR_MESSAGES.ABORTED if signal is pre-aborted", async () => { - const abortController = new AbortController(); - abortController.abort(); - await expect( - service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, {}, abortController.signal) - ).rejects.toThrow(ERROR_MESSAGES.ABORTED); - expect(mockAxiosInstance.request).not.toHaveBeenCalled(); - }); - - it("should throw immediately on axios cancellation and NOT retry", async () => { - axiosMock.isCancel.mockReturnValueOnce(true); - const errorMsg = "canceled"; - const cancelError = new Error(errorMsg); - Object.defineProperty(cancelError, "isCancel", { value: true, configurable: true }); - mockAxiosInstance.request.mockRejectedValue(cancelError); - - await expect( - service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, {}) - ).rejects.toThrow(errorMsg); - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); // No retries - }); - - it("should classify 500/502/503/504 as transient HTTP errors and retry", async () => { - // Return a 503 instead of throwing from axios, sendSafeRequest manually throws an internal format - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.SERVICE_UNAVAILABLE }); - - const requestPromise = service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, { maxRetries: DEFAULT_MAX_RETRIES }); - - // It sleeps for backoff between retries - await flushPromises(); - jest.advanceTimersByTime(FORWARDING_CONSTS.RETRY_BASE_DELAY_MS); - await flushPromises(); - jest.advanceTimersByTime(FORWARDING_CONSTS.RETRY_BASE_DELAY_MS * FORWARDING_CONSTS.RETRY_BACKOFF_BASE); - await flushPromises(); - - await expect(requestPromise).rejects.toMatchObject({ - code: `${FORWARDING_CONSTS.HTTP_PREFIX}${HTTP_STATUS.SERVICE_UNAVAILABLE}`, - isHttpError: true, - }); - // 1 initial + 2 retries - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES); - }); - - it("should classify ECONNRESET/ETIMEDOUT as transient network errors and retry", async () => { - const errorMsg = "socket hang up"; - /** @type {CommonError} */ - const networkErr = new Error(errorMsg); - networkErr.code = "ECONNRESET"; - mockAxiosInstance.request.mockRejectedValue(networkErr); - - const requestPromise = service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, { maxRetries: SHORT_MAX_RETRIES }); - - await flushPromises(); - jest.advanceTimersByTime(FORWARDING_CONSTS.RETRY_BASE_DELAY_MS); - await flushPromises(); - - await expect(requestPromise).rejects.toThrow(errorMsg); - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(SHORT_MAX_RETRIES); - }); - - it("should NOT retry 4xx errors (non-transient) and throw immediately, tripping CB", async () => { - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.NOT_FOUND }); - - // Pre-fill failures so this next error correctly opens the circuit breaker - for (let i = 0; i < CIRCUIT_BREAKER_FAILURE_COUNT - 1; i++) { - service.circuitBreaker.recordFailure(defaultUrl); - } - - await expect( - service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, { maxRetries: DEFAULT_MAX_RETRIES }) - ).rejects.toMatchObject({ - code: `${FORWARDING_CONSTS.HTTP_PREFIX}${HTTP_STATUS.NOT_FOUND}`, - }); - - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); // No retries - expect(service.circuitBreaker.isOpen(defaultUrl)).toBe(true); - }); - - it("should NOT follow redirects and instead throw as a failure (Security)", async () => { - // axios is configured with maxRedirects: 0, and our validateStatus treats them as failure manually - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.MOVED_PERMANENTLY }); - - await expect( - service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, {}) - ).rejects.toMatchObject({ - code: `${FORWARDING_CONSTS.HTTP_PREFIX}${HTTP_STATUS.MOVED_PERMANENTLY}`, - isHttpError: true, - }); - - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); - }); - - it("should exhaust maxRetries and throw the last encountered error", async () => { - const errorMsg = "network"; - /** @type {CommonError} */ - const networkErr = new Error(errorMsg); - networkErr.code = "ETIMEDOUT"; - - // Always fail - mockAxiosInstance.request.mockRejectedValue(networkErr); - - const promise = service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, { maxRetries: SHORT_MAX_RETRIES }); - - // SHORT_MAX_RETRIES total attempts - for (let i = 0; i < SHORT_MAX_RETRIES; i++) { - await flushPromises(); - jest.runOnlyPendingTimers(); - } - await flushPromises(); - - // Should throw the network error, not the exhaust messages (which is unreachable in current loop) - await expect(promise).rejects.toThrow(errorMsg); - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(SHORT_MAX_RETRIES); - }); - - it("should record a successful request against the circuit breaker", async () => { - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK, data: HTTP_STATUS_MESSAGES[HTTP_STATUS.OK] }); - - await service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, {}); - - // We can't directly check the 'recordSuccess' side-effect easily without a spy - // But we know it didn't crash, and the request was made. - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); - }); - - it("should wire up the abort signal to clear the retry delay timeout", async () => { - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.SERVICE_UNAVAILABLE }); - const abortController = new AbortController(); - - const requestPromise = service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, { maxRetries: DEFAULT_MAX_RETRIES }, abortController.signal); - - await flushPromises(); - // While it is sleeping for the first retry, abort it - abortController.abort(); - - await expect(requestPromise).rejects.toThrow(ERROR_MESSAGES.ABORTED); - // It shouldn't have executed the second attempt - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); - }); - - it("should throw REPLAY_ATTEMPTS_EXHAUSTED fallback if loop finishes without throwing", async () => { - // Unreachable in normal config since the inner loop throws the last error, - // but reachable if the loop never runs (e.g. maxRetries = -1). - const maxRetries = -1; - await expect( - service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, { maxRetries }) - ).rejects.toThrow(ERROR_MESSAGES.REPLAY_ATTEMPTS_EXHAUSTED(maxRetries)); - }); - - it("should log FORWARD_TIMEOUT if axiosError code is TIMEOUT_CODE", async () => { - /** @type {CommonError} */ - const err = (new Error("Timeout")); - err.code = FORWARDING_CONSTS.TIMEOUT_CODE; - mockAxiosInstance.request.mockRejectedValue(err); - - await expect( - service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, { maxRetries: 1 }) - ).rejects.toThrow(err); // Re-throws the native error, but the logger was hit + it("should strip FORWARD_HEADERS_TO_IGNORE when forwardHeaders is true", async () => { + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + const headers = { + host: "old.com", + [HTTP_HEADERS.CONTENT_LENGTH]: "100", + "safe-header": "test", + }; + await service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + headers, + { forwardHeaders: true }, + ); + + const reqConfig = mockAxiosInstance.request.mock.calls[0][0]; + expect(reqConfig.headers).toHaveProperty("safe-header", "test"); + expect(reqConfig.headers).not.toHaveProperty("host", "old.com"); // host is stripped + expect(reqConfig.headers).not.toHaveProperty(HTTP_HEADERS.CONTENT_LENGTH); + }); - expect(loggerMock.error).toHaveBeenCalledWith( - expect.objectContaining({ code: FORWARDING_CONSTS.TIMEOUT_CODE }), - ERROR_MESSAGES.FORWARD_TIMEOUT - ); - }); + it("should only forward Content-Type when forwardHeaders is false", async () => { + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + const headers = { + [HTTP_HEADERS.CONTENT_TYPE]: MIME_TYPES.JSON, + "safe-header": "test", + }; + await service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + headers, + { forwardHeaders: false }, + ); + + const reqConfig = mockAxiosInstance.request.mock.calls[0][0]; + expect(reqConfig.headers).toHaveProperty( + HTTP_HEADERS.CONTENT_TYPE, + MIME_TYPES.JSON, + ); + expect(reqConfig.headers).not.toHaveProperty("safe-header"); + }); - it("should handle environment without timer.unref safely", async () => { - const originalSetTimeout = global.setTimeout; - jest.spyOn(global, 'setTimeout').mockImplementation((cb, ms) => { - // The fake timer returns an integer id or object. - const timerId = originalSetTimeout(cb, ms); - if (timerId && typeof timerId === 'object') { - // @ts-expect-error - Delete mandatory unref property from timerId - delete timerId.unref; - } - return timerId; - }); - - /** @type {CommonError} */ - const err = new Error("ETIMEDOUT"); - err.code = "ETIMEDOUT"; - mockAxiosInstance.request.mockRejectedValue(err); - - const p = service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, { maxRetries: 2 }); - await flushPromises(); - jest.advanceTimersByTime(FORWARDING_CONSTS.RETRY_BASE_DELAY_MS); // Trigger retries - - // Will exhaust out - try { - await p; - } catch (e) { - // Ignore, we just want to ensure it doesn't crash on undefined unref - expect(/** @type {Error} */(e).message).toBe("ETIMEDOUT"); - } - - jest.restoreAllMocks(); - }); + it("should inject a custom hostHeader override if provided", async () => { + const newHost = "new-host.com"; + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + await service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + { hostHeader: newHost }, + ); + + const reqConfig = mockAxiosInstance.request.mock.calls[0][0]; + expect(reqConfig.headers).toHaveProperty(HTTP_HEADERS.HOST, newHost); }); - describe("forwardWebhook()", () => { - /** @type {WebhookEvent} */ - const mockEvent = assertType({ id: "ev_1", webhookId: "wh_1" }); - const mockOptions = { forwardHeaders: true, maxForwardRetries: 1 }; + it("should immediately throw ERROR_MESSAGES.ABORTED if signal is pre-aborted", async () => { + const abortController = new AbortController(); + abortController.abort(); + await expect( + service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + {}, + abortController.signal, + ), + ).rejects.toThrow(ERROR_MESSAGES.ABORTED); + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + }); - /** @type {CustomRequest} */ - let mockReq; - beforeEach(() => { - mockReq = createMockRequest({ - headers: { [HTTP_HEADERS.CONTENT_TYPE]: MIME_TYPES.JSON }, - body: { test: "data" } - }); - }); + it("should throw immediately on axios cancellation and NOT retry", async () => { + axiosMock.isCancel.mockReturnValueOnce(true); + const errorMsg = "canceled"; + const cancelError = new Error(errorMsg); + Object.defineProperty(cancelError, "isCancel", { + value: true, + configurable: true, + }); + mockAxiosInstance.request.mockRejectedValue(cancelError); + + await expect( + service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, {}), + ).rejects.toThrow(errorMsg); + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); // No retries + }); - it("should auto-prefix standard HTTP to URLs missing a protocol", async () => { - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - const forwardUrl = `${TEST_URL_HOST}/hook`; + it("should classify 500/502/503/504 as transient HTTP errors and retry", async () => { + // Return a 503 instead of throwing from axios, sendSafeRequest manually throws an internal format + mockAxiosInstance.request.mockResolvedValue({ + status: HTTP_STATUS.SERVICE_UNAVAILABLE, + }); + + const requestPromise = service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries: DEFAULT_MAX_RETRIES }, + ); + + // It sleeps for backoff between retries + await flushPromises(); + jest.advanceTimersByTime(FORWARDING_CONSTS.RETRY_BASE_DELAY_MS); + await flushPromises(); + jest.advanceTimersByTime( + FORWARDING_CONSTS.RETRY_BASE_DELAY_MS * + FORWARDING_CONSTS.RETRY_BACKOFF_BASE, + ); + await flushPromises(); + + await expect(requestPromise).rejects.toMatchObject({ + code: `${FORWARDING_CONSTS.HTTP_PREFIX}${HTTP_STATUS.SERVICE_UNAVAILABLE}`, + isHttpError: true, + }); + // 1 initial + 2 retries + expect(mockAxiosInstance.request).toHaveBeenCalledTimes( + DEFAULT_MAX_RETRIES, + ); + }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, forwardUrl); + it("should classify ECONNRESET/ETIMEDOUT as transient network errors and retry", async () => { + const errorMsg = "socket hang up"; + /** @type {CommonError} */ + const networkErr = new Error(errorMsg); + networkErr.code = "ECONNRESET"; + mockAxiosInstance.request.mockRejectedValue(networkErr); + + const requestPromise = service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries: SHORT_MAX_RETRIES }, + ); + + await flushPromises(); + jest.advanceTimersByTime(FORWARDING_CONSTS.RETRY_BASE_DELAY_MS); + await flushPromises(); + + await expect(requestPromise).rejects.toThrow(errorMsg); + expect(mockAxiosInstance.request).toHaveBeenCalledTimes( + SHORT_MAX_RETRIES, + ); + }); - expect(ssrfMock.validateUrlForSsrf).toHaveBeenCalledWith(`http://${forwardUrl}`); - expect(mockAxiosInstance.request).toHaveBeenCalledWith( - expect.objectContaining({ url: `http://${forwardUrl}` }) - ); - }); + it("should NOT retry 4xx errors (non-transient) and throw immediately, tripping CB", async () => { + mockAxiosInstance.request.mockResolvedValue({ + status: HTTP_STATUS.NOT_FOUND, + }); + + // Pre-fill failures so this next error correctly opens the circuit breaker + for (let i = 0; i < CIRCUIT_BREAKER_FAILURE_COUNT - 1; i++) { + service.circuitBreaker.recordFailure(defaultUrl); + } + + await expect( + service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries: DEFAULT_MAX_RETRIES }, + ), + ).rejects.toMatchObject({ + code: `${FORWARDING_CONSTS.HTTP_PREFIX}${HTTP_STATUS.NOT_FOUND}`, + }); + + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); // No retries + expect(service.circuitBreaker.isOpen(defaultUrl)).toBe(true); + }); - it("should immediately abort if Circuit Breaker is open for the URL", async () => { - service.circuitBreaker.recordFailure(TEST_BAD_URL); - // Need 5 failures by default to open - for (let i = 0; i < CIRCUIT_BREAKER_FAILURE_COUNT; i++) service.circuitBreaker.recordFailure(TEST_BAD_URL); + it("should NOT follow redirects and instead throw as a failure (Security)", async () => { + // axios is configured with maxRedirects: 0, and our validateStatus treats them as failure manually + mockAxiosInstance.request.mockResolvedValue({ + status: HTTP_STATUS.MOVED_PERMANENTLY, + }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_BAD_URL); + await expect( + service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, {}), + ).rejects.toMatchObject({ + code: `${FORWARDING_CONSTS.HTTP_PREFIX}${HTTP_STATUS.MOVED_PERMANENTLY}`, + isHttpError: true, + }); - expect(apifyMock.pushData).not.toHaveBeenCalled(); - expect(mockAxiosInstance.request).not.toHaveBeenCalled(); - }); + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); + }); - it("should block request if validateUrlForSsrf returns safe: false", async () => { - ssrfMock.validateUrlForSsrf.mockResolvedValue({ - safe: false, - error: "Reserved IP" - }); + it("should exhaust maxRetries and throw the last encountered error", async () => { + const errorMsg = "network"; + /** @type {CommonError} */ + const networkErr = new Error(errorMsg); + networkErr.code = "ETIMEDOUT"; + + // Always fail + mockAxiosInstance.request.mockRejectedValue(networkErr); + + const promise = service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries: SHORT_MAX_RETRIES }, + ); + + // SHORT_MAX_RETRIES total attempts + for (let i = 0; i < SHORT_MAX_RETRIES; i++) { + await flushPromises(); + jest.runOnlyPendingTimers(); + } + await flushPromises(); + + // Should throw the network error, not the exhaust messages (which is unreachable in current loop) + await expect(promise).rejects.toThrow(errorMsg); + expect(mockAxiosInstance.request).toHaveBeenCalledTimes( + SHORT_MAX_RETRIES, + ); + }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_BAD_URL); + it("should record a successful request against the circuit breaker", async () => { + mockAxiosInstance.request.mockResolvedValue({ + status: HTTP_STATUS.OK, + data: HTTP_STATUS_MESSAGES[HTTP_STATUS.OK], + }); - expect(apifyMock.pushData).not.toHaveBeenCalled(); - expect(mockAxiosInstance.request).not.toHaveBeenCalled(); - }); + await service.sendSafeRequest(defaultUrl, HTTP_METHODS.POST, {}, {}, {}); - it("should abort if the calculated body length exceeds MAX_ALLOWED_PAYLOAD_SIZE", async () => { - mockReq.headers[HTTP_HEADERS.CONTENT_LENGTH] = String(APP_CONSTS.MAX_ALLOWED_PAYLOAD_SIZE + MOCK_CONTENT_LENGTH); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTP); + // We can't directly check the 'recordSuccess' side-effect easily without a spy + // But we know it didn't crash, and the request was made. + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); + }); - expect(mockAxiosInstance.request).not.toHaveBeenCalled(); - expect(apifyMock.pushData).not.toHaveBeenCalled(); - }); + it("should wire up the abort signal to clear the retry delay timeout", async () => { + mockAxiosInstance.request.mockResolvedValue({ + status: HTTP_STATUS.SERVICE_UNAVAILABLE, + }); + const abortController = new AbortController(); + + const requestPromise = service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries: DEFAULT_MAX_RETRIES }, + abortController.signal, + ); + + await flushPromises(); + // While it is sleeping for the first retry, abort it + abortController.abort(); + + await expect(requestPromise).rejects.toThrow(ERROR_MESSAGES.ABORTED); + // It shouldn't have executed the second attempt + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); + }); - it("should fallback to buffer length if content-length header is missing", async () => { - delete mockReq.headers[HTTP_HEADERS.CONTENT_LENGTH]; - mockReq.body = "A".repeat(MOCK_CONTENT_LENGTH); - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + it("should throw REPLAY_ATTEMPTS_EXHAUSTED fallback if loop finishes without throwing", async () => { + // Unreachable in normal config since the inner loop throws the last error, + // but reachable if the loop never runs (e.g. maxRetries = -1). + const maxRetries = -1; + await expect( + service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries }, + ), + ).rejects.toThrow(ERROR_MESSAGES.REPLAY_ATTEMPTS_EXHAUSTED(maxRetries)); + }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTP); + it("should log FORWARD_TIMEOUT if axiosError code is TIMEOUT_CODE", async () => { + /** @type {CommonError} */ + const err = new Error("Timeout"); + err.code = FORWARDING_CONSTS.TIMEOUT_CODE; + mockAxiosInstance.request.mockRejectedValue(err); + + await expect( + service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries: 1 }, + ), + ).rejects.toThrow(err); // Re-throws the native error, but the logger was hit + + expect(loggerMock.error).toHaveBeenCalledWith( + expect.objectContaining({ code: FORWARDING_CONSTS.TIMEOUT_CODE }), + ERROR_MESSAGES.FORWARD_TIMEOUT, + ); + }); - expect(mockAxiosInstance.request).toHaveBeenCalled(); - expect(apifyMock.pushData).not.toHaveBeenCalled(); - }); + it("should handle environment without timer.unref safely", async () => { + const originalSetTimeout = global.setTimeout; + jest.spyOn(global, "setTimeout").mockImplementation((cb, ms) => { + // The fake timer returns an integer id or object. + const timerId = originalSetTimeout(cb, ms); + if (timerId && typeof timerId === "object") { + // @ts-expect-error - Delete mandatory unref property from timerId + delete timerId.unref; + } + return timerId; + }); + + /** @type {CommonError} */ + const err = new Error("ETIMEDOUT"); + err.code = "ETIMEDOUT"; + mockAxiosInstance.request.mockRejectedValue(err); + + const p = service.sendSafeRequest( + defaultUrl, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries: 2 }, + ); + await flushPromises(); + jest.advanceTimersByTime(FORWARDING_CONSTS.RETRY_BASE_DELAY_MS); // Trigger retries + + // Will exhaust out + try { + await p; + } catch (e) { + // Ignore, we just want to ensure it doesn't crash on undefined unref + expect(/** @type {Error} */ (e).message).toBe("ETIMEDOUT"); + } + + jest.restoreAllMocks(); + }); + }); - it("should gracefully handle non-json serializable bodies when length checking", async () => { - mockReq.headers = {}; - // Create a circular structure that throws on JSON.stringify - const circular = {}; - circular.self = circular; - mockReq.body = circular; - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + describe("forwardWebhook()", () => { + /** @type {WebhookEvent} */ + const mockEvent = assertType({ id: "ev_1", webhookId: "wh_1" }); + const mockOptions = { forwardHeaders: true, maxForwardRetries: 1 }; - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTP); + /** @type {CustomRequest} */ + let mockReq; + beforeEach(() => { + mockReq = createMockRequest({ + headers: { [HTTP_HEADERS.CONTENT_TYPE]: MIME_TYPES.JSON }, + body: { test: "data" }, + }); + }); - expect(mockAxiosInstance.request).toHaveBeenCalled(); - }); + it("should auto-prefix standard HTTP to URLs missing a protocol", async () => { + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + const forwardUrl = `${TEST_URL_HOST}/hook`; - it("should gracefully handle undefined request bodies when checking length", async () => { - mockReq.headers = {}; - mockReq.body = undefined; - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + await service.forwardWebhook(mockEvent, mockReq, mockOptions, forwardUrl); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTP); + expect(ssrfMock.validateUrlForSsrf).toHaveBeenCalledWith( + `http://${forwardUrl}`, + ); + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ url: `http://${forwardUrl}` }), + ); + }); - expect(mockAxiosInstance.request).toHaveBeenCalled(); - }); + it("should immediately abort if Circuit Breaker is open for the URL", async () => { + service.circuitBreaker.recordFailure(TEST_BAD_URL); + // Need 5 failures by default to open + for (let i = 0; i < CIRCUIT_BREAKER_FAILURE_COUNT; i++) + service.circuitBreaker.recordFailure(TEST_BAD_URL); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_BAD_URL, + ); + + expect(apifyMock.pushData).not.toHaveBeenCalled(); + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + }); - it("should always inject the recursion header to prevent forwarding loops", async () => { - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + it("should block request if validateUrlForSsrf returns safe: false", async () => { + ssrfMock.validateUrlForSsrf.mockResolvedValue({ + safe: false, + error: "Reserved IP", + }); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_BAD_URL, + ); + + expect(apifyMock.pushData).not.toHaveBeenCalled(); + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTP); + it("should abort if the calculated body length exceeds MAX_ALLOWED_PAYLOAD_SIZE", async () => { + mockReq.headers[HTTP_HEADERS.CONTENT_LENGTH] = String( + APP_CONSTS.MAX_ALLOWED_PAYLOAD_SIZE + MOCK_CONTENT_LENGTH, + ); + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTP, + ); + + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + expect(apifyMock.pushData).not.toHaveBeenCalled(); + }); - expect(mockAxiosInstance.request).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - [RECURSION_HEADER_NAME]: RECURSION_HEADER_VALUE - }) - }) - ); - }); + it("should fallback to buffer length if content-length header is missing", async () => { + delete mockReq.headers[HTTP_HEADERS.CONTENT_LENGTH]; + mockReq.body = "A".repeat(MOCK_CONTENT_LENGTH); + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - it("should push error data to Actor when sending fails", async () => { - /** @type {CommonError} */ - const err = new Error("Network timeout"); - err.code = "ETIMEDOUT"; - mockAxiosInstance.request.mockRejectedValue(err); - - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - - expect(apifyMock.pushData).toHaveBeenCalledWith( - expect.objectContaining({ - id: expect.any(String), - webhookId: mockEvent.webhookId, - type: ERROR_LABELS.FORWARD_ERROR, - body: expect.stringContaining(err.code), - }) - ); - }); + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTP, + ); - it("should use a generic error message for unapproved failure codes to avoid leaking data", async () => { - const err = new Error("Sensitive Internal Data Leak: API Key 123"); - // No reliable code/response mapped => Unknown error - mockAxiosInstance.request.mockRejectedValue(err); - - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - - expect(apifyMock.pushData).toHaveBeenCalledWith( - expect.objectContaining({ - id: expect.any(String), - webhookId: mockEvent.webhookId, - type: ERROR_LABELS.FORWARD_ERROR, - body: expect.stringContaining(ERROR_MESSAGES.FORWARD_REQUEST_FAILED) - }) - ); - }); + expect(mockAxiosInstance.request).toHaveBeenCalled(); + expect(apifyMock.pushData).not.toHaveBeenCalled(); + }); - it("should accurately capture HTTP status codes natively thrown from sendSafeRequest", async () => { - /** @type {CommonError} */ - const err = new Error(ERROR_LABELS.FORBIDDEN); - err.response = { status: HTTP_STATUS.FORBIDDEN }; - err.isHttpError = true; - err.code = `${FORWARDING_CONSTS.HTTP_PREFIX}${HTTP_STATUS.FORBIDDEN}`; - mockAxiosInstance.request.mockRejectedValue(err); - - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - - expect(apifyMock.pushData).toHaveBeenCalledWith( - expect.objectContaining({ - id: expect.any(String), - webhookId: mockEvent.webhookId, - type: ERROR_LABELS.FORWARD_ERROR, - body: expect.stringContaining(String(HTTP_STATUS.FORBIDDEN)) - }) - ); - }); + it.each([ + ["empty", ""], + ["non-numeric", "not-a-number"], + ["partially numeric", "123abc"], + ["scientific notation", "1e9"], + ["unsafe integer", "9007199254740992"], + ])( + "should fallback to measured body size when content-length header is %s", + async (_label, headerValue) => { + mockReq.headers[HTTP_HEADERS.CONTENT_LENGTH] = headerValue; + mockReq.body = "A".repeat(APP_CONSTS.MAX_ALLOWED_PAYLOAD_SIZE + 1); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTP, + ); + + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + expect(apifyMock.pushData).not.toHaveBeenCalled(); + }, + ); + + it("should fallback to measured body size when content-length header is an array", async () => { + mockReq.headers[HTTP_HEADERS.CONTENT_LENGTH] = assertType(["1", "999"]); + mockReq.body = "A".repeat(APP_CONSTS.MAX_ALLOWED_PAYLOAD_SIZE + 1); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTP, + ); + + expect(mockAxiosInstance.request).not.toHaveBeenCalled(); + expect(apifyMock.pushData).not.toHaveBeenCalled(); + }); - it("should classify 500 errors as transient and include transient label in pushData", async () => { - /** @type {CommonError} */ - const err = new Error("Internal Server Error"); - err.isHttpError = true; - err.response = { status: HTTP_STATUS.INTERNAL_SERVER_ERROR }; - mockAxiosInstance.request.mockRejectedValue(err); - - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - - expect(apifyMock.pushData).toHaveBeenCalledWith( - expect.objectContaining({ - id: expect.any(String), - webhookId: mockEvent.webhookId, - type: ERROR_LABELS.FORWARD_ERROR, - body: expect.not.stringContaining("Non-transient error/Permanent Failure") - }) - ); - expect(apifyMock.pushData).toHaveBeenCalledWith( - expect.objectContaining({ - id: expect.any(String), - webhookId: mockEvent.webhookId, - type: ERROR_LABELS.FORWARD_ERROR, - body: expect.stringContaining(`Request failed with status code ${HTTP_STATUS.INTERNAL_SERVER_ERROR}`) - }) - ); - }); + it("should gracefully handle non-json serializable bodies when length checking", async () => { + mockReq.headers = {}; + // Create a circular structure that throws on JSON.stringify + const circular = {}; + circular.self = circular; + mockReq.body = circular; + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTP, + ); + + expect(mockAxiosInstance.request).toHaveBeenCalled(); + }); - it("should swallow Actor.pushData failures silently rather than crashing the background worker", async () => { - /** @type {CommonError} */ - const err = new Error("ETIMEDOUT"); - err.code = "ETIMEDOUT"; - mockAxiosInstance.request.mockRejectedValue(err); + it("should gracefully handle undefined request bodies when checking length", async () => { + mockReq.headers = {}; + mockReq.body = undefined; + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - // The push data fails! - apifyMock.pushData.mockRejectedValue(new Error("Apify API Offline")); + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTP, + ); - const promise = service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - // it should NOT reject. The promise should resolve despite errors under the hood. - await expect(promise).resolves.toBeUndefined(); - }); + expect(mockAxiosInstance.request).toHaveBeenCalled(); + }); - it("should gracefully ignore if string length calculation triggers", async () => { - const bufferBody = Buffer.alloc(MOCK_BUFFER_SIZE); - mockReq.body = bufferBody; - delete mockReq.headers[HTTP_HEADERS.CONTENT_LENGTH]; - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HOST); - expect(mockAxiosInstance.request).toHaveBeenCalled(); - }); + it("should always inject the recursion header to prevent forwarding loops", async () => { + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTP, + ); + + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + [RECURSION_HEADER_NAME]: RECURSION_HEADER_VALUE, + }), + }), + ); + }); - it("should fallback to default maxRetries if options.maxForwardRetries is undefined", async () => { - const optionsNoRetries = { forwardHeaders: true }; // maxForwardRetries is undefined - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - await service.forwardWebhook(mockEvent, mockReq, optionsNoRetries, TEST_URL_HTTPS); - expect(mockAxiosInstance.request).toHaveBeenCalled(); - }); + it("should push error data to Actor when sending fails", async () => { + /** @type {CommonError} */ + const err = new Error("Network timeout"); + err.code = "ETIMEDOUT"; + mockAxiosInstance.request.mockRejectedValue(err); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + + expect(apifyMock.pushData).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + webhookId: mockEvent.webhookId, + type: ERROR_LABELS.FORWARD_ERROR, + body: expect.stringContaining(err.code), + }), + ); + }); - it("should fallback to validatedUrl if ssrfResult.href is missing", async () => { - ssrfMock.validateUrlForSsrf.mockResolvedValue({ safe: true, host: TEST_URL_HOST }); // no href - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - expect(mockAxiosInstance.request).toHaveBeenCalledWith( - expect.objectContaining({ url: TEST_URL_HTTPS }) - ); - }); + it("should use a generic error message for unapproved failure codes to avoid leaking data", async () => { + const err = new Error("Sensitive Internal Data Leak: API Key 123"); + // No reliable code/response mapped => Unknown error + mockAxiosInstance.request.mockRejectedValue(err); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + + expect(apifyMock.pushData).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + webhookId: mockEvent.webhookId, + type: ERROR_LABELS.FORWARD_ERROR, + body: expect.stringContaining(ERROR_MESSAGES.FORWARD_REQUEST_FAILED), + }), + ); + }); - it("should fallback to empty string if ssrfResult.host is missing", async () => { - ssrfMock.validateUrlForSsrf.mockResolvedValue({ safe: true, href: TEST_URL_HTTPS }); // no host - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - expect(mockAxiosInstance.request).toHaveBeenCalledWith( - expect.objectContaining({ headers: expect.any(Object) }) - ); - }); + it("should accurately capture HTTP status codes natively thrown from sendSafeRequest", async () => { + /** @type {CommonError} */ + const err = new Error(ERROR_LABELS.FORBIDDEN); + err.response = { status: HTTP_STATUS.FORBIDDEN }; + err.isHttpError = true; + err.code = `${FORWARDING_CONSTS.HTTP_PREFIX}${HTTP_STATUS.FORBIDDEN}`; + mockAxiosInstance.request.mockRejectedValue(err); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + + expect(apifyMock.pushData).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + webhookId: mockEvent.webhookId, + type: ERROR_LABELS.FORWARD_ERROR, + body: expect.stringContaining(String(HTTP_STATUS.FORBIDDEN)), + }), + ); + }); - it("should return immediately and not pushError if signal is aborted during catch block", async () => { - const controller = new AbortController(); - const err = new Error("Abort"); - mockAxiosInstance.request.mockRejectedValue(err); - controller.abort(); + it("should classify 500 errors as transient and include transient label in pushData", async () => { + /** @type {CommonError} */ + const err = new Error("Internal Server Error"); + err.isHttpError = true; + err.response = { status: HTTP_STATUS.INTERNAL_SERVER_ERROR }; + mockAxiosInstance.request.mockRejectedValue(err); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + + expect(apifyMock.pushData).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + webhookId: mockEvent.webhookId, + type: ERROR_LABELS.FORWARD_ERROR, + body: expect.not.stringContaining( + "Non-transient error/Permanent Failure", + ), + }), + ); + expect(apifyMock.pushData).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.any(String), + webhookId: mockEvent.webhookId, + type: ERROR_LABELS.FORWARD_ERROR, + body: expect.stringContaining( + `Request failed with status code ${HTTP_STATUS.INTERNAL_SERVER_ERROR}`, + ), + }), + ); + }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS, controller.signal); + it("should swallow Actor.pushData failures silently rather than crashing the background worker", async () => { + /** @type {CommonError} */ + const err = new Error("ETIMEDOUT"); + err.code = "ETIMEDOUT"; + mockAxiosInstance.request.mockRejectedValue(err); + + // The push data fails! + apifyMock.pushData.mockRejectedValue(new Error("Apify API Offline")); + + const promise = service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + // it should NOT reject. The promise should resolve despite errors under the hood. + await expect(promise).resolves.toBeUndefined(); + }); - expect(apifyMock.pushData).not.toHaveBeenCalled(); - }); + it("should gracefully ignore if string length calculation triggers", async () => { + const bufferBody = Buffer.alloc(MOCK_BUFFER_SIZE); + mockReq.body = bufferBody; + delete mockReq.headers[HTTP_HEADERS.CONTENT_LENGTH]; + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HOST, + ); + expect(mockAxiosInstance.request).toHaveBeenCalled(); + }); - it("should correctly handle HTTP error without response status in catch block", async () => { - /** @type {CommonError} */ - const err = new Error("Empty HTTP Error"); - err.isHttpError = true; - // No status property here - err.response = {}; - mockAxiosInstance.request.mockRejectedValue(err); - - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - - expect(apifyMock.pushData).toHaveBeenCalledWith( - expect.objectContaining({ - type: ERROR_LABELS.FORWARD_ERROR, - body: expect.stringContaining("Request failed with status code 0") - }) - ); - }); + it("should fallback to default maxRetries if options.maxForwardRetries is undefined", async () => { + const optionsNoRetries = { forwardHeaders: true }; // maxForwardRetries is undefined + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + await service.forwardWebhook( + mockEvent, + mockReq, + optionsNoRetries, + TEST_URL_HTTPS, + ); + expect(mockAxiosInstance.request).toHaveBeenCalled(); + }); - it("should correctly trip the circuit breaker under concurrent failing requests (Stress/Concurrency)", async () => { - const failingUrl = "https://unstable-target.com"; - ssrfMock.validateUrlForSsrf.mockResolvedValue({ safe: true, href: failingUrl, host: "unstable-target.com" }); + it("should fallback to validatedUrl if ssrfResult.href is missing", async () => { + ssrfMock.validateUrlForSsrf.mockResolvedValue({ + safe: true, + host: TEST_URL_HOST, + }); // no href + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ url: TEST_URL_HTTPS }), + ); + }); - // Rejects with a non-transient error so it increments failure immediately - /** @type {CommonError} */ - const err = new Error("Connection Refused"); - err.code = "ECONNREFUSED"; - mockAxiosInstance.request.mockRejectedValue(err); + it("should fallback to empty string if ssrfResult.host is missing", async () => { + ssrfMock.validateUrlForSsrf.mockResolvedValue({ + safe: true, + href: TEST_URL_HTTPS, + }); // no host + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); - // Execute multiple concurrent requests - const requestedCount = CIRCUIT_BREAKER_FAILURE_COUNT + 1; - const promises = Array.from({ length: requestedCount }).map(() => - service.forwardWebhook(mockEvent, mockReq, mockOptions, failingUrl) - ); + it("should return immediately and not pushError if signal is aborted during catch block", async () => { + const controller = new AbortController(); + const err = new Error("Abort"); + mockAxiosInstance.request.mockRejectedValue(err); + controller.abort(); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + controller.signal, + ); + + expect(apifyMock.pushData).not.toHaveBeenCalled(); + }); - await Promise.all(promises); + it("should correctly handle HTTP error without response status in catch block", async () => { + /** @type {CommonError} */ + const err = new Error("Empty HTTP Error"); + err.isHttpError = true; + // No status property here + err.response = {}; + mockAxiosInstance.request.mockRejectedValue(err); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + + expect(apifyMock.pushData).toHaveBeenCalledWith( + expect.objectContaining({ + type: ERROR_LABELS.FORWARD_ERROR, + body: expect.stringContaining("Request failed with status code 0"), + }), + ); + }); - // The circuit breaker should now be open for this URL - expect(service.circuitBreaker.isOpen(failingUrl)).toBe(true); + it("should correctly trip the circuit breaker under concurrent failing requests (Stress/Concurrency)", async () => { + const failingUrl = "https://unstable-target.com"; + ssrfMock.validateUrlForSsrf.mockResolvedValue({ + safe: true, + href: failingUrl, + host: "unstable-target.com", + }); + + // Rejects with a non-transient error so it increments failure immediately + /** @type {CommonError} */ + const err = new Error("Connection Refused"); + err.code = "ECONNREFUSED"; + mockAxiosInstance.request.mockRejectedValue(err); + + // Execute multiple concurrent requests + const requestedCount = CIRCUIT_BREAKER_FAILURE_COUNT + 1; + const promises = Array.from({ length: requestedCount }).map(() => + service.forwardWebhook(mockEvent, mockReq, mockOptions, failingUrl), + ); + + await Promise.all(promises); + + // The circuit breaker should now be open for this URL + expect(service.circuitBreaker.isOpen(failingUrl)).toBe(true); + + // Subsequent requests should be blocked immediately (already covered by another test but good for flow) + await service.forwardWebhook(mockEvent, mockReq, mockOptions, failingUrl); + // No new axios calls + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(requestedCount); + }); - // Subsequent requests should be blocked immediately (already covered by another test but good for flow) - await service.forwardWebhook(mockEvent, mockReq, mockOptions, failingUrl); - // No new axios calls - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(requestedCount); + describe("hardening (audit feedback)", () => { + it("should clear Prior circuit breaker failures on success (recordSuccess)", async () => { + const url = TEST_URL_HTTPS; + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + + // Prime with some failures + for (let i = 0; i < CIRCUIT_BREAKER_FAILURE_COUNT - 1; i++) { + service.circuitBreaker.recordFailure(url); + } + const host = new URL(url).host; + expect(service.circuitBreaker.states?.get?.(host)?.failures).toBe( + CIRCUIT_BREAKER_FAILURE_COUNT - 1, + ); + + await service.sendSafeRequest(url, HTTP_METHODS.POST, {}, {}, {}); + + // Success should have cleared the state (since it's not open) + expect(service.circuitBreaker.states?.has?.(host)).toBe(false); + }); + + it("should log FAILED_LOG_FORWARD when Actor.pushData fails", async () => { + mockAxiosInstance.request.mockRejectedValue( + new Error("Request Failed"), + ); + const pushErr = new Error("Push Failed"); + apifyMock.pushData.mockRejectedValue(pushErr); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + + expect(loggerMock.error).toHaveBeenCalledWith( + expect.objectContaining({ err: expect.any(Object) }), + LOG_MESSAGES.FAILED_LOG_FORWARD, + ); + }); + + it("should pass HTTPS URLs to validateUrlForSsrf unchanged", async () => { + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + + expect(ssrfMock.validateUrlForSsrf).toHaveBeenCalledWith( + TEST_URL_HTTPS, + ); + }); + + it("should NOT trip circuit breaker on first transient failure but trip on exhaustion", async () => { + const url = TEST_URL_HTTPS; + const host = new URL(url).host; + /** @type {CommonError} */ + const transientErr = new Error("Transient"); + transientErr.code = "ETIMEDOUT"; + mockAxiosInstance.request.mockRejectedValue(transientErr); + + // Run with retries + const maxRetries = 2; + const promise = service.sendSafeRequest( + url, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries }, + ); + + // First attempt fails + await flushPromises(); + expect(service.circuitBreaker.states.has(host)).toBe(false); // Not tripped yet + + // Advance to exhaust + jest.advanceTimersByTime(FORWARDING_CONSTS.RETRY_BASE_DELAY_MS); + await flushPromises(); + + await expect(promise).rejects.toThrow(); + expect(service.circuitBreaker.states.get(host)?.failures).toBe(1); // Tripped after exhaustion + }); + + it("should rethrow and skip retries if signal aborted mid-request (catch block)", async () => { + const controller = new AbortController(); + const url = TEST_URL_HTTPS; + + /** @type {CommonError} */ + const err = new Error("Some Error"); + err.code = "ECONNRESET"; // Usually transient and retried + + // Mock request to abort mid-stream (logic-wise we simulate it being aborted when catch runs) + mockAxiosInstance.request.mockImplementation(async () => { + controller.abort(); + throw err; }); - describe("hardening (audit feedback)", () => { - it("should clear Prior circuit breaker failures on success (recordSuccess)", async () => { - const url = TEST_URL_HTTPS; - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - - // Prime with some failures - for (let i = 0; i < CIRCUIT_BREAKER_FAILURE_COUNT - 1; i++) { - service.circuitBreaker.recordFailure(url); - } - const host = new URL(url).host; - expect(service.circuitBreaker.states?.get?.(host)?.failures).toBe(CIRCUIT_BREAKER_FAILURE_COUNT - 1); - - await service.sendSafeRequest(url, HTTP_METHODS.POST, {}, {}, {}); - - // Success should have cleared the state (since it's not open) - expect(service.circuitBreaker.states?.has?.(host)).toBe(false); - }); - - it("should log FAILED_LOG_FORWARD when Actor.pushData fails", async () => { - mockAxiosInstance.request.mockRejectedValue(new Error("Request Failed")); - const pushErr = new Error("Push Failed"); - apifyMock.pushData.mockRejectedValue(pushErr); - - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - - expect(loggerMock.error).toHaveBeenCalledWith( - expect.objectContaining({ err: expect.any(Object) }), - LOG_MESSAGES.FAILED_LOG_FORWARD - ); - }); - - it("should pass HTTPS URLs to validateUrlForSsrf unchanged", async () => { - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - - expect(ssrfMock.validateUrlForSsrf).toHaveBeenCalledWith(TEST_URL_HTTPS); - }); - - it("should NOT trip circuit breaker on first transient failure but trip on exhaustion", async () => { - const url = TEST_URL_HTTPS; - const host = new URL(url).host; - /** @type {CommonError} */ - const transientErr = new Error("Transient"); - transientErr.code = "ETIMEDOUT"; - mockAxiosInstance.request.mockRejectedValue(transientErr); - - // Run with retries - const maxRetries = 2; - const promise = service.sendSafeRequest(url, HTTP_METHODS.POST, {}, {}, { maxRetries }); - - // First attempt fails - await flushPromises(); - expect(service.circuitBreaker.states.has(host)).toBe(false); // Not tripped yet - - // Advance to exhaust - jest.advanceTimersByTime(FORWARDING_CONSTS.RETRY_BASE_DELAY_MS); - await flushPromises(); - - await expect(promise).rejects.toThrow(); - expect(service.circuitBreaker.states.get(host)?.failures).toBe(1); // Tripped after exhaustion - }); - - it("should rethrow and skip retries if signal aborted mid-request (catch block)", async () => { - const controller = new AbortController(); - const url = TEST_URL_HTTPS; - - /** @type {CommonError} */ - const err = new Error("Some Error"); - err.code = "ECONNRESET"; // Usually transient and retried - - // Mock request to abort mid-stream (logic-wise we simulate it being aborted when catch runs) - mockAxiosInstance.request.mockImplementation(async () => { - controller.abort(); - throw err; - }); - - const promise = service.sendSafeRequest(url, HTTP_METHODS.POST, {}, {}, { maxRetries: 3 }, controller.signal); - - await expect(promise).rejects.toThrow("Some Error"); - expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); // No retries because signal was aborted - }); - - it("should default forwardHeaders to true when options.forwardHeaders is undefined", async () => { - mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); - const headers = { "x-custom": "value" }; - const options = { forwardHeaders: undefined }; // Should behave as true - - await service.sendSafeRequest(TEST_URL_HTTPS, HTTP_METHODS.POST, {}, headers, options); - - const reqConfig = mockAxiosInstance.request.mock.calls[0][0]; - expect(reqConfig.headers).toHaveProperty("x-custom", "value"); - }); - - it("should use generic message for unknown non-transient error codes during normalization", async () => { - /** @type {CommonError} */ - const err = new Error("Internal Secret"); - const errCode = "CUSTOM_UNKNOWN_CODE"; - err.code = errCode; - mockAxiosInstance.request.mockRejectedValue(err); - - await service.forwardWebhook(mockEvent, mockReq, mockOptions, TEST_URL_HTTPS); - - expect(apifyMock.pushData).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.stringContaining(ERROR_MESSAGES.FORWARD_REQUEST_FAILED) - }) - ); - expect(apifyMock.pushData).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.not.stringContaining(errCode) - }) - ); - }); - }); + const promise = service.sendSafeRequest( + url, + HTTP_METHODS.POST, + {}, + {}, + { maxRetries: 3 }, + controller.signal, + ); + + await expect(promise).rejects.toThrow("Some Error"); + expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); // No retries because signal was aborted + }); + + it("should default forwardHeaders to true when options.forwardHeaders is undefined", async () => { + mockAxiosInstance.request.mockResolvedValue({ status: HTTP_STATUS.OK }); + const headers = { "x-custom": "value" }; + const options = { forwardHeaders: undefined }; // Should behave as true + + await service.sendSafeRequest( + TEST_URL_HTTPS, + HTTP_METHODS.POST, + {}, + headers, + options, + ); + + const reqConfig = mockAxiosInstance.request.mock.calls[0][0]; + expect(reqConfig.headers).toHaveProperty("x-custom", "value"); + }); + + it("should use generic message for unknown non-transient error codes during normalization", async () => { + /** @type {CommonError} */ + const err = new Error("Internal Secret"); + const errCode = "CUSTOM_UNKNOWN_CODE"; + err.code = errCode; + mockAxiosInstance.request.mockRejectedValue(err); + + await service.forwardWebhook( + mockEvent, + mockReq, + mockOptions, + TEST_URL_HTTPS, + ); + + expect(apifyMock.pushData).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining( + ERROR_MESSAGES.FORWARD_REQUEST_FAILED, + ), + }), + ); + expect(apifyMock.pushData).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.not.stringContaining(errCode), + }), + ); + }); }); + }); }); diff --git a/tests/unit/utils/common.test.js b/tests/unit/utils/common.test.js index 4928ae4..2ca2832 100644 --- a/tests/unit/utils/common.test.js +++ b/tests/unit/utils/common.test.js @@ -89,7 +89,6 @@ describe("Common Utils", () => { describe("deepRedact", () => { const SENSITIVE_VAL = "sensitive-value"; - // eslint-disable-next-line sonarjs/no-hardcoded-passwords const PASSWORD_VAL = "password123"; it("should redact sensitive keys", () => {