From cfb2c08744dfc0e8afdfe6318f84ba3a0c268838 Mon Sep 17 00:00:00 2001 From: Nicolas Hansse Date: Wed, 27 Aug 2025 09:54:00 +0200 Subject: [PATCH 1/2] fix(http): do not recreate agent for each request --- src/__tests__/httpURLConnectionClient.spec.ts | 184 ++++-- src/httpClient/httpURLConnectionClient.ts | 546 ++++++++++-------- 2 files changed, 442 insertions(+), 288 deletions(-) diff --git a/src/__tests__/httpURLConnectionClient.spec.ts b/src/__tests__/httpURLConnectionClient.spec.ts index d9e71d4a9..9674f02f6 100644 --- a/src/__tests__/httpURLConnectionClient.spec.ts +++ b/src/__tests__/httpURLConnectionClient.spec.ts @@ -1,62 +1,138 @@ +import { globalAgent } from "node:https"; import HttpURLConnectionClient from "../httpClient/httpURLConnectionClient"; +import HttpClientException from "../httpClient/httpClientException"; describe("HttpURLConnectionClient", () => { - let client: HttpURLConnectionClient; + let client: HttpURLConnectionClient; - beforeEach(() => { - client = new HttpURLConnectionClient(); + beforeEach(() => { + client = new HttpURLConnectionClient(); + }); + + describe("verifyLocation", () => { + test.each([ + "https://example.adyen.com/path", + "https://sub.adyen.com", + "http://another.adyen.com/a/b/c?q=1", + "https://checkout-test.adyen.com", + ])("should return true for valid adyen.com domain: %s", (location) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(client.verifyLocation(location)).toBe(true); + }); + + test.each(["https://example.ADYEN.com/path", "HTTPS://sub.adyen.COM"])( + "should be case-insensitive for valid adyen.com domain: %s", + (location) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(client.verifyLocation(location)).toBe(true); + } + ); + + test.each([ + "https://adyen.com.evil.com/path", + "https://evil-adyen.com", + "http://adyen.co", + "https://www.google.com", + "https://adyen.com-scam.com", + ])("should return false for invalid domain: %s", (location) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(client.verifyLocation(location)).toBe(false); + }); + + test.each([ + "https://adyen.com.another.domain/path", + "https://myadyen.com.org", + ])( + "should return false for domains that contain but do not end with adyen.com: %s", + (location) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(client.verifyLocation(location)).toBe(false); + } + ); + + test.each(["not a url", "adyen.com", "//adyen.com/path"])( + "should return false for malformed URLs: %s", + (location) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(client.verifyLocation(location)).toBe(false); + } + ); + }); + + describe("selectAgent", () => { + it("returns globalAgent if terminalCertificatePath is undefined", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(client.selectAgent()).toBe(globalAgent); }); - describe("verifyLocation", () => { - test.each([ - "https://example.adyen.com/path", - "https://sub.adyen.com", - "http://another.adyen.com/a/b/c?q=1", - "https://checkout-test.adyen.com", - ])("should return true for valid adyen.com domain: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(true); - }); - - test.each([ - "https://example.ADYEN.com/path", - "HTTPS://sub.adyen.COM", - ])("should be case-insensitive for valid adyen.com domain: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(true); - }); - - test.each([ - "https://adyen.com.evil.com/path", - "https://evil-adyen.com", - "http://adyen.co", - "https://www.google.com", - "https://adyen.com-scam.com", - ])("should return false for invalid domain: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(false); - }); - - test.each([ - "https://adyen.com.another.domain/path", - "https://myadyen.com.org", - ])("should return false for domains that contain but do not end with adyen.com: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(false); - }); - - test.each([ - "not a url", - "adyen.com", - "//adyen.com/path", - ])("should return false for malformed URLs: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(false); - }); + it("returns new Agent if terminalCertificatePath is 'unencrypted'", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + const agent = client.selectAgent("unencrypted"); + expect(agent.options.keepAlive).toBe(true); + expect(agent.options.scheduling).toBe("lifo"); + expect(agent.options.timeout).toBe(5000); + expect(agent.options.rejectUnauthorized).toBe(false); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).length).toBe(1); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).at(0)).toBe("unencrypted"); + }); + + it("returns new Agent if terminalCertificatePath is 'path/to/this/file'", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + const agent = client.selectAgent(__filename); + expect(agent.options.keepAlive).toBe(true); + expect(agent.options.scheduling).toBe("lifo"); + expect(agent.options.timeout).toBe(5000); + expect(agent.options.ca).toBeDefined(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).length).toBe(1); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).at(0)).toBe(__filename); + }); + + it("returns already existing Agent if terminalCertificatePath is asked twice", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + const agent = client.selectAgent(__filename); // create and store an agent + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + const agent2 = client.selectAgent(__filename); // should return the one already in memory + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).length).toBe(1); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).at(0)).toBe(__filename); + + expect(agent).toBe(agent2); + }); + + it("throw an error if terminalCertificatePath is not a valid path", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(() => client.selectAgent("/invalid/path")).toThrow( + HttpClientException + ); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).length).toBe(0); }); + }); }); diff --git a/src/httpClient/httpURLConnectionClient.ts b/src/httpClient/httpURLConnectionClient.ts index afbb55007..ef08da2b3 100644 --- a/src/httpClient/httpURLConnectionClient.ts +++ b/src/httpClient/httpURLConnectionClient.ts @@ -17,8 +17,18 @@ * See the LICENSE file for more info. */ -import { ClientRequest, IncomingHttpHeaders, IncomingMessage, request as httpRequest } from "http"; -import { Agent, AgentOptions, request as httpsRequest } from "https"; +import { + ClientRequest, + IncomingHttpHeaders, + IncomingMessage, + request as httpRequest, +} from "http"; +import { + Agent, + AgentOptions, + globalAgent, + request as httpsRequest, +} from "https"; import { HttpsProxyAgent } from "https-proxy-agent"; import * as fs from "fs"; @@ -35,258 +45,326 @@ import { IRequest } from "../typings/requestOptions"; import checkServerIdentity from "../helpers/checkServerIdentity"; class HttpURLConnectionClient implements ClientInterface { - private static CHARSET = "utf-8"; - public proxy?: AgentOptions; - private agentOptions!: AgentOptions; - - /** - * Sends an HTTP request to the specified endpoint with the provided JSON payload and configuration. - * - * This method sets up request headers, including authentication (API key or basic auth), content type, - * and timeout. If a certificate path is provided in the config, it installs a certificate verifier. - * Throws an ApiException when an error occurs (invalid API key, API error response, etc.). - * - * @param endpoint - The URL to which the request will be sent. - * @param json - The JSON string to be sent as the request body. - * @param config - The configuration object containing authentication, timeout, and certificate details. - * @param isApiRequired - Indicates whether an API key is required for this request. - * @param requestOptions - Additional options for the HTTP request, such as headers and timeout. - * @returns A promise that resolves with the response body as a string. - * @throws {ApiException} when an error occurs - */ - public request( - endpoint: string, - json: string, - config: Config, - isApiRequired: boolean, - requestOptions: IRequest.Options, - ): Promise { - requestOptions.headers ??= {}; - requestOptions.timeout = config.connectionTimeoutMillis; - - if (config.certificatePath) { - this.installCertificateVerifier(config.certificatePath); - } - - const apiKey = config.apiKey; - - if (isApiRequired && !apiKey) { - return Promise.reject(new ApiException("Invalid X-API-Key was used", 401)); - } - - if (apiKey) { - requestOptions.headers[ApiConstants.API_KEY] = apiKey; - } else { - const authString = `${config.username}:${config.password}`; - const authStringEnc = Buffer.from(authString, "utf8").toString("base64"); - - requestOptions.headers.Authorization = `Basic ${authStringEnc}`; - } - - requestOptions.headers[ApiConstants.CONTENT_TYPE] = ApiConstants.APPLICATION_JSON_TYPE; - - const httpConnection: ClientRequest = this.createRequest(endpoint, requestOptions, config.applicationName); - return this.doRequest(httpConnection, json); + private static readonly AGENT_INACTIVITY_TIMEOUT = 5000; + private static CHARSET = "utf-8"; + public proxy?: AgentOptions; + private agents: Record = {}; + + /** + * Sends an HTTP request to the specified endpoint with the provided JSON payload and configuration. + * + * This method sets up request headers, including authentication (API key or basic auth), content type, + * and timeout. If a certificate path is provided in the config, it installs a certificate verifier. + * Throws an ApiException when an error occurs (invalid API key, API error response, etc.). + * + * @param endpoint - The URL to which the request will be sent. + * @param json - The JSON string to be sent as the request body. + * @param config - The configuration object containing authentication, timeout, and certificate details. + * @param isApiRequired - Indicates whether an API key is required for this request. + * @param requestOptions - Additional options for the HTTP request, such as headers and timeout. + * @returns A promise that resolves with the response body as a string. + * @throws {ApiException} when an error occurs + */ + public request( + endpoint: string, + json: string, + config: Config, + isApiRequired: boolean, + requestOptions: IRequest.Options + ): Promise { + requestOptions.headers ??= {}; + requestOptions.timeout = config.connectionTimeoutMillis; + + const agent = this.selectAgent(config.certificatePath); + + const apiKey = config.apiKey; + + if (isApiRequired && !apiKey) { + return Promise.reject( + new ApiException("Invalid X-API-Key was used", 401) + ); } - // create Request object - private createRequest(endpoint: string, requestOptions: IRequest.Options, applicationName?: string): ClientRequest { - if (!requestOptions.headers) { - requestOptions.headers = {}; - } - - const url = new URL(endpoint); - requestOptions.hostname = url.hostname; - requestOptions.protocol = url.protocol; - requestOptions.port = url.port; - requestOptions.path = url.pathname; - - if (requestOptions.params) { - requestOptions.path += "?" + new URLSearchParams(requestOptions.params).toString(); - } - - if (requestOptions && requestOptions.idempotencyKey) { - requestOptions.headers[ApiConstants.IDEMPOTENCY_KEY] = requestOptions.idempotencyKey; - delete requestOptions.idempotencyKey; - } - - if (this.proxy && this.proxy.host) { - const { host, port, ...options } = this.proxy; - requestOptions.agent = new HttpsProxyAgent({ host, port: port || 443, ...options }); - } else { - requestOptions.agent = new Agent(this.agentOptions); - } - - requestOptions.headers["Cache-Control"] = "no-cache"; - - if (!requestOptions.method) { - requestOptions.method = ApiConstants.METHOD_POST; - } - - requestOptions.headers[ApiConstants.ACCEPT_CHARSET] = HttpURLConnectionClient.CHARSET; - // user-agent header - const libInfo = `${LibraryConstants.LIB_NAME}/${LibraryConstants.LIB_VERSION}`; - requestOptions.headers[ApiConstants.USER_AGENT] = applicationName ? `${applicationName} ${libInfo}` : libInfo; - // custom headers - requestOptions.headers[ApiConstants.ADYEN_LIBRARY_NAME] = LibraryConstants.LIB_NAME; - requestOptions.headers[ApiConstants.ADYEN_LIBRARY_VERSION] = LibraryConstants.LIB_VERSION; - - // create a new ClientRequest object - const req = httpsRequest(requestOptions); - - // set the timeout on the ClientRequest instance - if (requestOptions.timeout) { - req.setTimeout(requestOptions.timeout); - } - - return req; + if (apiKey) { + requestOptions.headers[ApiConstants.API_KEY] = apiKey; + } else { + const authString = `${config.username}:${config.password}`; + const authStringEnc = Buffer.from(authString, "utf8").toString("base64"); + + requestOptions.headers.Authorization = `Basic ${authStringEnc}`; } - // invoke request - private doRequest(connectionRequest: ClientRequest, json: string): Promise { - return new Promise((resolve, reject): void => { - connectionRequest.flushHeaders(); + requestOptions.headers[ApiConstants.CONTENT_TYPE] = + ApiConstants.APPLICATION_JSON_TYPE; + + const httpConnection: ClientRequest = this.createRequest( + endpoint, + requestOptions, + agent, + config.applicationName + ); + return this.doRequest(httpConnection, json); + } + + // create Request object + private createRequest( + endpoint: string, + requestOptions: IRequest.Options, + agent: Agent, + applicationName?: string + ): ClientRequest { + if (!requestOptions.headers) { + requestOptions.headers = {}; + } - connectionRequest.on("response", (res: IncomingMessage): void => { - const response: { headers: IncomingHttpHeaders; body: string; statusCode: number | undefined } = { - statusCode: res.statusCode, - headers: res.headers, - body: "" - }; + const url = new URL(endpoint); + requestOptions.hostname = url.hostname; + requestOptions.protocol = url.protocol; + requestOptions.port = url.port; + requestOptions.path = url.pathname; - // define default exception (in case of error during the handling of the response) - const getException = (responseBody: string): HttpClientException => new HttpClientException({ - message: `HTTP Exception: ${response.statusCode}. ${res.statusMessage}`, - statusCode: response.statusCode, - errorCode: undefined, - responseHeaders: response.headers, - responseBody, - }); - let exception: HttpClientException | Error = getException(response.body); + if (requestOptions.params) { + requestOptions.path += + "?" + new URLSearchParams(requestOptions.params).toString(); + } - res.on("data", (chunk: string): void => { - response.body += chunk; - }); + if (requestOptions && requestOptions.idempotencyKey) { + requestOptions.headers[ApiConstants.IDEMPOTENCY_KEY] = + requestOptions.idempotencyKey; + delete requestOptions.idempotencyKey; + } - res.on("end", (): void => { - if (!res.complete) { - reject(new Error("The connection was terminated while the message was still being sent")); - } - - // Handle 308 redirect - if (res.statusCode && res.statusCode === 308) { - const location = res.headers["location"]; - if (location) { - // follow the redirect - try { - const url = new URL(location); - - if (!this.verifyLocation(location)) { - return reject(new Error(`Redirect to host ${url.hostname} is not allowed.`)); - } - - const newRequestOptions = { - hostname: url.hostname, - port: url.port || (url.protocol === "https:" ? 443 : 80), - path: url.pathname + url.search, - method: connectionRequest.method, - headers: connectionRequest.getHeaders(), - protocol: url.protocol, - }; - const clientRequestFn = url.protocol === "https:" ? httpsRequest : httpRequest; - const redirectedRequest: ClientRequest = clientRequestFn(newRequestOptions); - const redirectResponse = this.doRequest(redirectedRequest, json); - return resolve(redirectResponse); - } catch (err) { - return reject(err); - } - } else { - return reject(new Error(`Redirect status ${res.statusCode} - Could not find location in response headers`)); - } - } - - if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { - // API error handling - try { - const formattedData: ApiError | { [key: string]: never } = JSON.parse(response.body); - const isApiError = "status" in formattedData; - const isRequestError = "errors" in formattedData; - - if (isApiError) { - // Adyen API has returned an error - exception = new HttpClientException({ - message: `HTTP Exception: ${formattedData.status}. ${res.statusMessage}: ${formattedData.message}`, - statusCode: formattedData.status, - errorCode: formattedData.errorCode, - responseHeaders: res.headers, - responseBody: response.body, - apiError: formattedData, - }); - } else if (isRequestError) { - exception = new Error(response.body); - } else { - exception = getException(response.body); - } - } catch (e) { - // parsing error - exception = new HttpClientException({ - message: `HTTP Exception: ${response.statusCode}. Error parsing response: ${(e as Error).message}`, - statusCode: response.statusCode, - responseHeaders: response.headers, - responseBody: response.body, - }); - } - - return reject(exception); - } - - resolve(response.body as string); - }); + if (this.proxy && this.proxy.host) { + const { host, port, ...options } = this.proxy; + requestOptions.agent = new HttpsProxyAgent({ + host, + port: port || 443, + ...options, + }); + } else { + requestOptions.agent = agent; + } - res.on("error", reject); - }); + requestOptions.headers["Cache-Control"] = "no-cache"; - connectionRequest.on("timeout", (): void => { - connectionRequest.abort(); - }); - connectionRequest.on("error", (e) => reject(new ApiException(e.message))); - connectionRequest.write(Buffer.from(json)); - connectionRequest.end(); - }); + if (!requestOptions.method) { + requestOptions.method = ApiConstants.METHOD_POST; } - private installCertificateVerifier(terminalCertificatePath: string): void | Promise { - try { - if (terminalCertificatePath == "unencrypted") { - this.agentOptions = { - rejectUnauthorized: false + requestOptions.headers[ApiConstants.ACCEPT_CHARSET] = + HttpURLConnectionClient.CHARSET; + // user-agent header + const libInfo = `${LibraryConstants.LIB_NAME}/${LibraryConstants.LIB_VERSION}`; + requestOptions.headers[ApiConstants.USER_AGENT] = applicationName + ? `${applicationName} ${libInfo}` + : libInfo; + // custom headers + requestOptions.headers[ApiConstants.ADYEN_LIBRARY_NAME] = + LibraryConstants.LIB_NAME; + requestOptions.headers[ApiConstants.ADYEN_LIBRARY_VERSION] = + LibraryConstants.LIB_VERSION; + + // create a new ClientRequest object + const req = httpsRequest(requestOptions); + + // set the timeout on the ClientRequest instance + if (requestOptions.timeout) { + req.setTimeout(requestOptions.timeout); + } + + return req; + } + + // invoke request + private doRequest( + connectionRequest: ClientRequest, + json: string + ): Promise { + return new Promise((resolve, reject): void => { + connectionRequest.flushHeaders(); + + connectionRequest.on("response", (res: IncomingMessage): void => { + const response: { + headers: IncomingHttpHeaders; + body: string; + statusCode: number | undefined; + } = { + statusCode: res.statusCode, + headers: res.headers, + body: "", + }; + + // define default exception (in case of error during the handling of the response) + const getException = (responseBody: string): HttpClientException => + new HttpClientException({ + message: `HTTP Exception: ${response.statusCode}. ${res.statusMessage}`, + statusCode: response.statusCode, + errorCode: undefined, + responseHeaders: response.headers, + responseBody, + }); + let exception: HttpClientException | Error = getException( + response.body + ); + + res.on("data", (chunk: string): void => { + response.body += chunk; + }); + + res.on("end", (): void => { + if (!res.complete) { + reject( + new Error( + "The connection was terminated while the message was still being sent" + ) + ); + } + + // Handle 308 redirect + if (res.statusCode && res.statusCode === 308) { + const location = res.headers["location"]; + if (location) { + // follow the redirect + try { + const url = new URL(location); + + if (!this.verifyLocation(location)) { + return reject( + new Error( + `Redirect to host ${url.hostname} is not allowed.` + ) + ); + } + + const newRequestOptions = { + hostname: url.hostname, + port: url.port || (url.protocol === "https:" ? 443 : 80), + path: url.pathname + url.search, + method: connectionRequest.method, + headers: connectionRequest.getHeaders(), + protocol: url.protocol, }; + const clientRequestFn = + url.protocol === "https:" ? httpsRequest : httpRequest; + const redirectedRequest: ClientRequest = + clientRequestFn(newRequestOptions); + const redirectResponse = this.doRequest( + redirectedRequest, + json + ); + return resolve(redirectResponse); + } catch (err) { + return reject(err); + } } else { - const certificateInput = fs.readFileSync(terminalCertificatePath); - this.agentOptions = { - ca: certificateInput, - checkServerIdentity, - }; + return reject( + new Error( + `Redirect status ${res.statusCode} - Could not find location in response headers` + ) + ); + } + } + + if ( + res.statusCode && + (res.statusCode < 200 || res.statusCode >= 300) + ) { + // API error handling + try { + const formattedData: ApiError | { [key: string]: never } = + JSON.parse(response.body); + const isApiError = "status" in formattedData; + const isRequestError = "errors" in formattedData; + + if (isApiError) { + // Adyen API has returned an error + exception = new HttpClientException({ + message: `HTTP Exception: ${formattedData.status}. ${res.statusMessage}: ${formattedData.message}`, + statusCode: formattedData.status, + errorCode: formattedData.errorCode, + responseHeaders: res.headers, + responseBody: response.body, + apiError: formattedData, + }); + } else if (isRequestError) { + exception = new Error(response.body); + } else { + exception = getException(response.body); + } + } catch (e) { + // parsing error + exception = new HttpClientException({ + message: `HTTP Exception: ${ + response.statusCode + }. Error parsing response: ${(e as Error).message}`, + statusCode: response.statusCode, + responseHeaders: response.headers, + responseBody: response.body, + }); } - } catch (e) { - const message = e instanceof Error ? e.message : "undefined"; - return Promise.reject(new HttpClientException({ message: `Error loading certificate from path: ${message}` })); - } + return reject(exception); + } + resolve(response.body as string); + }); + + res.on("error", reject); + }); + + connectionRequest.on("timeout", (): void => { + connectionRequest.abort(); + }); + connectionRequest.on("error", (e) => reject(new ApiException(e.message))); + connectionRequest.write(Buffer.from(json)); + connectionRequest.end(); + }); + } + + private verifyLocation(location: string): boolean { + try { + const url = new URL(location); + // allow-list of trusted domains (*.adyen.com) + const allowedHostnameRegex = /\.adyen\.com$/i; + return allowedHostnameRegex.test(url.hostname); + } catch (e) { + return false; } + } - private verifyLocation(location: string): boolean { - try { - const url = new URL(location); - // allow-list of trusted domains (*.adyen.com) - const allowedHostnameRegex = /\.adyen\.com$/i; - return allowedHostnameRegex.test(url.hostname); - } catch (e) { - return false; - } + private selectAgent(terminalCertificatePath?: string): Agent { + if (!terminalCertificatePath) { + return globalAgent; } -} + if (this.agents[terminalCertificatePath]) { + return this.agents[terminalCertificatePath]; + } + + const agentOptions: AgentOptions = { + keepAlive: true, + scheduling: "lifo", + timeout: HttpURLConnectionClient.AGENT_INACTIVITY_TIMEOUT, + }; + + if (terminalCertificatePath === "unencrypted") { + agentOptions.rejectUnauthorized = false; + } else { + try { + agentOptions.ca = fs.readFileSync(terminalCertificatePath); + agentOptions.checkServerIdentity = checkServerIdentity; + } catch (e) { + const message = e instanceof Error ? e.message : "undefined"; + throw new HttpClientException({ + message: `Error loading certificate from path: ${message}`, + }); + } + } + + const newAgent = new Agent(agentOptions); + this.agents[terminalCertificatePath] = newAgent; + return newAgent; + } +} export default HttpURLConnectionClient; From 7ab69104fe9deee710f5973c21e0a30709d372c4 Mon Sep 17 00:00:00 2001 From: Beppe Catanese <1771700+gcatanese@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:12:32 +0200 Subject: [PATCH 2/2] Correct error from resolve conflicts merge --- src/__tests__/httpURLConnectionClient.spec.ts | 224 +++++++----------- 1 file changed, 90 insertions(+), 134 deletions(-) diff --git a/src/__tests__/httpURLConnectionClient.spec.ts b/src/__tests__/httpURLConnectionClient.spec.ts index 1903975bc..989c14f6c 100644 --- a/src/__tests__/httpURLConnectionClient.spec.ts +++ b/src/__tests__/httpURLConnectionClient.spec.ts @@ -15,20 +15,21 @@ describe("HttpURLConnectionClient", () => { "https://sub.adyen.com", "http://another.adyen.com/a/b/c?q=1", "https://checkout-test.adyen.com", + "https://custom-url.adyenpayments.com", ])("should return true for valid adyen.com domain: %s", (location) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - testing a private method expect(client.verifyLocation(location)).toBe(true); }); - test.each(["https://example.ADYEN.com/path", "HTTPS://sub.adyen.COM"])( - "should be case-insensitive for valid adyen.com domain: %s", - (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(true); - } - ); + test.each([ + "https://example.ADYEN.com/path", + "HTTPS://sub.adyen.COM", + ])("should be case-insensitive for valid adyen.com domain: %s", (location) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(client.verifyLocation(location)).toBe(true); + }); test.each([ "https://adyen.com.evil.com/path", @@ -45,147 +46,102 @@ describe("HttpURLConnectionClient", () => { test.each([ "https://adyen.com.another.domain/path", "https://myadyen.com.org", - ])( - "should return false for domains that contain but do not end with adyen.com: %s", - (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(false); - } - ); - - test.each(["not a url", "adyen.com", "//adyen.com/path"])( - "should return false for malformed URLs: %s", - (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(false); - } - ); - }); - - describe("selectAgent", () => { - it("returns globalAgent if terminalCertificatePath is undefined", () => { + ])("should return false for domains that contain but do not end with adyen.com: %s", (location) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - testing a private method - expect(client.selectAgent()).toBe(globalAgent); + expect(client.verifyLocation(location)).toBe(false); }); - it("returns new Agent if terminalCertificatePath is 'unencrypted'", () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - const agent = client.selectAgent("unencrypted"); - expect(agent.options.keepAlive).toBe(true); - expect(agent.options.scheduling).toBe("lifo"); - expect(agent.options.timeout).toBe(5000); - expect(agent.options.rejectUnauthorized).toBe(false); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(Object.keys(client.agents).length).toBe(1); + test.each([ + "not a url", + "adyen.com", + "//adyen.com/path", + ])("should return false for malformed URLs: %s", (location) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - testing a private method - expect(Object.keys(client.agents).at(0)).toBe("unencrypted"); + expect(client.verifyLocation(location)).toBe(false); }); + }); - it("returns new Agent if terminalCertificatePath is 'path/to/this/file'", () => { + test.each(["not a url", "adyen.com", "//adyen.com/path"])( + "should return false for malformed URLs: %s", + (location) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - testing a private method - const agent = client.selectAgent(__filename); - expect(agent.options.keepAlive).toBe(true); - expect(agent.options.scheduling).toBe("lifo"); - expect(agent.options.timeout).toBe(5000); - expect(agent.options.ca).toBeDefined(); + expect(client.verifyLocation(location)).toBe(false); + } + ); +}); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(Object.keys(client.agents).length).toBe(1); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(Object.keys(client.agents).at(0)).toBe(__filename); - }); +describe("selectAgent", () => { + it("returns globalAgent if terminalCertificatePath is undefined", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(client.selectAgent()).toBe(globalAgent); + }); - it("returns already existing Agent if terminalCertificatePath is asked twice", () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - const agent = client.selectAgent(__filename); // create and store an agent - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - const agent2 = client.selectAgent(__filename); // should return the one already in memory + it("returns new Agent if terminalCertificatePath is 'unencrypted'", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + const agent = client.selectAgent("unencrypted"); + expect(agent.options.keepAlive).toBe(true); + expect(agent.options.scheduling).toBe("lifo"); + expect(agent.options.timeout).toBe(5000); + expect(agent.options.rejectUnauthorized).toBe(false); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).length).toBe(1); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).at(0)).toBe("unencrypted"); + }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(Object.keys(client.agents).length).toBe(1); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(Object.keys(client.agents).at(0)).toBe(__filename); + it("returns new Agent if terminalCertificatePath is 'path/to/this/file'", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + const agent = client.selectAgent(__filename); + expect(agent.options.keepAlive).toBe(true); + expect(agent.options.scheduling).toBe("lifo"); + expect(agent.options.timeout).toBe(5000); + expect(agent.options.ca).toBeDefined(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).length).toBe(1); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).at(0)).toBe(__filename); + }); - expect(agent).toBe(agent2); - }); + it("returns already existing Agent if terminalCertificatePath is asked twice", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + const agent = client.selectAgent(__filename); // create and store an agent + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + const agent2 = client.selectAgent(__filename); // should return the one already in memory + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).length).toBe(1); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).at(0)).toBe(__filename); + + expect(agent).toBe(agent2); + }); - it("throw an error if terminalCertificatePath is not a valid path", () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(() => client.selectAgent("/invalid/path")).toThrow( - HttpClientException - ); + it("throw an error if terminalCertificatePath is not a valid path", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(() => client.selectAgent("/invalid/path")).toThrow( + HttpClientException + ); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(Object.keys(client.agents).length).toBe(0); - - describe("verifyLocation", () => { - test.each([ - "https://example.adyen.com/path", - "https://sub.adyen.com", - "http://another.adyen.com/a/b/c?q=1", - "https://checkout-test.adyen.com", - "https://custom-url.adyenpayments.com", - ])("should return true for valid adyen.com domain: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(true); - }); - - test.each([ - "https://example.ADYEN.com/path", - "HTTPS://sub.adyen.COM", - ])("should be case-insensitive for valid adyen.com domain: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(true); - }); - - test.each([ - "https://adyen.com.evil.com/path", - "https://evil-adyen.com", - "http://adyen.co", - "https://www.google.com", - "https://adyen.com-scam.com", - ])("should return false for invalid domain: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(false); - }); - - test.each([ - "https://adyen.com.another.domain/path", - "https://myadyen.com.org", - ])("should return false for domains that contain but do not end with adyen.com: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(false); - }); - - test.each([ - "not a url", - "adyen.com", - "//adyen.com/path", - ])("should return false for malformed URLs: %s", (location) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - testing a private method - expect(client.verifyLocation(location)).toBe(false); - }); - }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - testing a private method + expect(Object.keys(client.agents).length).toBe(0); }); + });