diff --git a/src/__tests__/httpURLConnectionClient.spec.ts b/src/__tests__/httpURLConnectionClient.spec.ts index bbd3a30f7..989c14f6c 100644 --- a/src/__tests__/httpURLConnectionClient.spec.ts +++ b/src/__tests__/httpURLConnectionClient.spec.ts @@ -1,63 +1,147 @@ +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", + "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); }); - 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); - }); + 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); + }); + }); + + 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); + }); + + 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 4dabd00a7..6674f5841 100644 --- a/src/httpClient/httpURLConnectionClient.ts +++ b/src/httpClient/httpURLConnectionClient.ts @@ -18,7 +18,7 @@ */ import { ClientRequest, IncomingHttpHeaders, IncomingMessage, request as httpRequest } from "http"; -import { Agent, AgentOptions, request as httpsRequest } from "https"; +import { Agent, AgentOptions, globalAgent, request as httpsRequest } from "https"; import { HttpsProxyAgent } from "https-proxy-agent"; import * as fs from "fs"; @@ -35,9 +35,11 @@ import { IRequest } from "../typings/requestOptions"; import checkServerIdentity from "../helpers/checkServerIdentity"; class HttpURLConnectionClient implements ClientInterface { + private static readonly AGENT_INACTIVITY_TIMEOUT = 5000; private static CHARSET = "utf-8"; public proxy?: AgentOptions; private agentOptions!: AgentOptions; + private agents: Record = {}; /** * Sends an HTTP request to the specified endpoint with the provided JSON payload and configuration. @@ -64,9 +66,7 @@ class HttpURLConnectionClient implements ClientInterface { requestOptions.headers ??= {}; requestOptions.timeout = config.connectionTimeoutMillis; - if (config.certificatePath) { - this.installCertificateVerifier(config.certificatePath); - } + const agent = this.selectAgent(config.certificatePath); const apiKey = config.apiKey; @@ -85,13 +85,13 @@ class HttpURLConnectionClient implements ClientInterface { requestOptions.headers[ApiConstants.CONTENT_TYPE] = ApiConstants.APPLICATION_JSON_TYPE; - const httpConnection: ClientRequest = this.createRequest(endpoint, requestOptions, config.applicationName); + const httpConnection: ClientRequest = this.createRequest(endpoint, requestOptions, agent, config.applicationName); return this.doRequest(httpConnection, json, config.enable308Redirect ?? true); } // create Request object - private createRequest(endpoint: string, requestOptions: IRequest.Options, applicationName?: string): ClientRequest { + private createRequest(endpoint: string, requestOptions: IRequest.Options, agent: Agent, applicationName?: string): ClientRequest { if (!requestOptions.headers) { requestOptions.headers = {}; } @@ -118,6 +118,18 @@ class HttpURLConnectionClient implements ClientInterface { requestOptions.agent = new Agent(this.agentOptions); } + 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; + } + + requestOptions.headers["Cache-Control"] = "no-cache"; if (!requestOptions.method) { @@ -264,27 +276,6 @@ class HttpURLConnectionClient implements ClientInterface { }); } - private installCertificateVerifier(terminalCertificatePath: string): void | Promise { - try { - if (terminalCertificatePath == "unencrypted") { - this.agentOptions = { - rejectUnauthorized: false - }; - } else { - const certificateInput = fs.readFileSync(terminalCertificatePath); - this.agentOptions = { - ca: certificateInput, - checkServerIdentity, - }; - } - - } catch (e) { - const message = e instanceof Error ? e.message : "undefined"; - return Promise.reject(new HttpClientException({ message: `Error loading certificate from path: ${message}` })); - } - - } - private verifyLocation(location: string): boolean { try { const url = new URL(location); @@ -295,6 +286,64 @@ class HttpURLConnectionClient implements ClientInterface { return false; } } + + /** + * Selects or creates an HTTPS Agent for a given terminal certificate path. + * + * - If no `terminalCertificatePath` is provided, the global HTTPS agent is returned. + * - If an agent for the given path already exists in the cache, it is reused. + * - Otherwise, a new agent is created with: + * - Keep-alive enabled + * - LIFO socket scheduling (to reduce stale connection reuse) + * - A configurable inactivity timeout + * - Either: + * - Certificate validation disabled if path is `"unencrypted"`, or + * - A custom CA loaded from the provided certificate file and + * `checkServerIdentity` enabled for hostname verification. + * + * @param {string} [terminalCertificatePath] - Path to a terminal certificate file, + * or the literal string `"unencrypted"` to disable TLS verification. + * If omitted, the global agent will be returned. + * + * @returns {Agent} The HTTPS agent + * + * @throws {HttpClientException} When an error occurs + */ + + private selectAgent(terminalCertificatePath?: string): Agent { + if (!terminalCertificatePath) { + return globalAgent; + } + + if (this.agents[terminalCertificatePath]) { + return this.agents[terminalCertificatePath]; + } + + const agentOptions: AgentOptions = { + keepAlive: true, + scheduling: "lifo", // re-use latest connections sockets + timeout: HttpURLConnectionClient.AGENT_INACTIVITY_TIMEOUT, // inactivity timeout for connection sockets + }; + + 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; + } + }