Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 139 additions & 55 deletions src/__tests__/httpURLConnectionClient.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});

});
103 changes: 76 additions & 27 deletions src/httpClient/httpURLConnectionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string, Agent> = {};

/**
* Sends an HTTP request to the specified endpoint with the provided JSON payload and configuration.
Expand All @@ -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;

Expand All @@ -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 = {};
}
Expand All @@ -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) {
Expand Down Expand Up @@ -264,27 +276,6 @@ class HttpURLConnectionClient implements ClientInterface {
});
}

private installCertificateVerifier(terminalCertificatePath: string): void | Promise<HttpClientException> {
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);
Expand All @@ -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;
}

}


Expand Down
Loading