Merge pull request #3860 from bluewave-labs/feat/proxy-resolver
feat: proxy resolver
This commit is contained in:
16 files changed
+677
-20
No files matched your search
Generated
+10
@@ -26,6 +26,7 @@
|
||||
"got": "14.4.7",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^8.0.0",
|
||||
"hpagent": "1.2.0",
|
||||
"isomorphic-dompurify": "^2.26.0",
|
||||
"jmespath": "^0.16.0",
|
||||
"jsdom": "^26.1.0",
|
||||
@@ -7778,6 +7779,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hpagent": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz",
|
||||
"integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"got": "14.4.7",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^8.0.0",
|
||||
"hpagent": "1.2.0",
|
||||
"isomorphic-dompurify": "^2.26.0",
|
||||
"jmespath": "^0.16.0",
|
||||
"jsdom": "^26.1.0",
|
||||
|
||||
@@ -26,7 +26,7 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js";
|
||||
import { IncidentReactor } from "@/worker/reactors/reactor.incident.js";
|
||||
import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
|
||||
import { DBQueueWorker } from "@/worker/worker.db-queue.js";
|
||||
|
||||
import { ProxyResolver } from "@/service/network/ProxyResolver.js";
|
||||
// Network providers
|
||||
import { PingProvider } from "@/service/network/PingProvider.js";
|
||||
import { HttpProvider } from "@/service/network/HttpProvider.js";
|
||||
@@ -64,6 +64,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
incidentsRepository,
|
||||
teamsRepository,
|
||||
maintenanceWindowsRepository,
|
||||
proxiesRepository,
|
||||
} = shared;
|
||||
|
||||
// ***********************
|
||||
@@ -93,6 +94,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
dnsProvider,
|
||||
]);
|
||||
|
||||
const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger);
|
||||
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository);
|
||||
const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository);
|
||||
const monitorStatusPolicy = new MonitorStatusPolicy();
|
||||
@@ -110,7 +112,15 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig
|
||||
// Check producer/evaluator
|
||||
// Handles creating and evaluatiog checks
|
||||
// ***********************
|
||||
const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger);
|
||||
const checkProducer = new CheckProducer(
|
||||
monitorsRepository,
|
||||
maintenanceWindowsRepository,
|
||||
checkService,
|
||||
networkService,
|
||||
proxyResolver,
|
||||
bufferService,
|
||||
logger
|
||||
);
|
||||
const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy);
|
||||
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface IProxiesRepository {
|
||||
create(proxyData: Partial<Proxy>): Promise<Proxy>;
|
||||
// read
|
||||
findById(proxyId: string, teamId: string): Promise<Proxy>;
|
||||
findByIdOrNull(proxyId: string, teamId?: string): Promise<Proxy | null>;
|
||||
findByTeamId(teamId: string): Promise<Proxy[]>;
|
||||
// update
|
||||
updateById(proxyId: string, teamId: string, patch: Partial<Proxy>, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise<Proxy>;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Proxy } from "@/domain/proxies/proxy.type.js";
|
||||
import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { toStringId, toDateString } from "@/utils/mongoMappers.js";
|
||||
import { UpdateQuery } from "mongoose";
|
||||
import { Error as MongooseError, UpdateQuery } from "mongoose";
|
||||
|
||||
const SERVICE_NAME = "ProxiesRepository";
|
||||
|
||||
@@ -45,6 +45,18 @@ class MongoProxiesRepository implements IProxiesRepository {
|
||||
return this.toEntity(proxy);
|
||||
}
|
||||
|
||||
async findByIdOrNull(proxyId: string, teamId?: string): Promise<Proxy | null> {
|
||||
try {
|
||||
const proxy = await ProxyModel.findOne(teamId ? { _id: proxyId, teamId } : { _id: proxyId });
|
||||
return proxy ? this.toEntity(proxy) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof MongooseError.CastError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<Proxy[]> {
|
||||
const proxies = await ProxyModel.find({ teamId });
|
||||
return proxies.map(this.toEntity);
|
||||
|
||||
@@ -9,11 +9,17 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { isStatusUp } from "@/service/network/utils.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
import CacheableLookup from "cacheable-lookup";
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
|
||||
import { CheckContext } from "@/types/network.js";
|
||||
|
||||
export const PROXY_CONNECT_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE"]);
|
||||
|
||||
export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
readonly type = "http";
|
||||
|
||||
// Shared, pooled agents reused across every check
|
||||
private static readonly PROXY_AGENT_CACHE_MAX = 50;
|
||||
private readonly proxyAgents = new Map<string, { http: HttpProxyAgent; https: HttpsProxyAgent }>();
|
||||
private readonly httpAgent: HttpAgent;
|
||||
private readonly httpsAgent: HttpsAgent;
|
||||
private readonly httpsAgentInsecure: HttpsAgent;
|
||||
@@ -41,6 +47,44 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
return type === "http";
|
||||
}
|
||||
|
||||
private proxyFailureMessage = (error: RequestError, proxyUrl: string): string | undefined => {
|
||||
if (error.response || !error.code || !PROXY_CONNECT_ERROR_CODES.has(error.code)) {
|
||||
return undefined;
|
||||
}
|
||||
const { hostname, port } = new URL(proxyUrl);
|
||||
return `Proxy connection failed (${hostname}:${port}): ${error.message}`;
|
||||
};
|
||||
|
||||
private getProxyAgents(proxyUrl: string, ignoreTlsErrors: boolean) {
|
||||
const key = `${proxyUrl}|${ignoreTlsErrors}`;
|
||||
const hit = this.proxyAgents.get(key);
|
||||
// Agent already exists, refresh
|
||||
if (hit) {
|
||||
this.proxyAgents.delete(key); // Refresh
|
||||
this.proxyAgents.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
// Create new agents
|
||||
const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256, proxy: proxyUrl };
|
||||
const pair = {
|
||||
http: new HttpProxyAgent(agentOptions),
|
||||
https: new HttpsProxyAgent({ ...agentOptions, rejectUnauthorized: !ignoreTlsErrors }),
|
||||
};
|
||||
|
||||
// Cache agents
|
||||
this.proxyAgents.set(key, pair);
|
||||
if (this.proxyAgents.size > HttpProvider.PROXY_AGENT_CACHE_MAX) {
|
||||
const [oldestKey] = this.proxyAgents.keys(); // Destructuring gets the first inserted, ie oldest, item
|
||||
if (oldestKey === undefined) return pair;
|
||||
const oldest = this.proxyAgents.get(oldestKey);
|
||||
oldest?.http.destroy();
|
||||
oldest?.https.destroy();
|
||||
this.proxyAgents.delete(oldestKey);
|
||||
}
|
||||
return pair;
|
||||
}
|
||||
|
||||
private buildResponse<T>(
|
||||
monitor: Monitor,
|
||||
opts: {
|
||||
@@ -113,20 +157,22 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
private handleHttpError<T>(error: unknown, monitor: Monitor): MonitorStatusResponse<T> {
|
||||
private handleHttpError<T>(error: unknown, monitor: Monitor, ctx?: CheckContext): MonitorStatusResponse<T> {
|
||||
if (error instanceof HTTPError || error instanceof RequestError) {
|
||||
const statusCode = error.response?.statusCode;
|
||||
const statusUp = isStatusUp(statusCode, monitor.customUpCodes);
|
||||
const responseTime = error.timings?.phases?.total ?? 0;
|
||||
|
||||
if (!statusUp) {
|
||||
const message = (ctx?.proxyUrl && error instanceof RequestError && this.proxyFailureMessage(error, ctx.proxyUrl)) || error.message;
|
||||
|
||||
return {
|
||||
monitorId: monitor.id,
|
||||
teamId: monitor.teamId,
|
||||
type: monitor.type,
|
||||
status: false,
|
||||
code: statusCode ?? NETWORK_ERROR,
|
||||
message: error.message,
|
||||
message: message,
|
||||
responseTime,
|
||||
timings: error.timings,
|
||||
payload: null as T,
|
||||
@@ -156,7 +202,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
};
|
||||
}
|
||||
|
||||
async handle<T>(monitor: Monitor): Promise<MonitorStatusResponse<T>> {
|
||||
async handle<T>(monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<T>> {
|
||||
const { url, secret, ignoreTlsErrors } = monitor;
|
||||
|
||||
if (!url) {
|
||||
@@ -167,10 +213,12 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined,
|
||||
};
|
||||
|
||||
options.agent = {
|
||||
http: this.httpAgent,
|
||||
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
|
||||
};
|
||||
options.agent = ctx?.proxyUrl
|
||||
? this.getProxyAgents(ctx.proxyUrl, Boolean(ignoreTlsErrors))
|
||||
: {
|
||||
http: this.httpAgent,
|
||||
https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent,
|
||||
};
|
||||
|
||||
options.method = monitor.method;
|
||||
|
||||
@@ -188,7 +236,7 @@ export class HttpProvider implements IStatusProvider<HttpStatusPayload> {
|
||||
timings: response.timings,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return this.handleHttpError(error, monitor);
|
||||
return this.handleHttpError(error, monitor, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { MonitorStatusResponse } from "@/types/network.js";
|
||||
|
||||
import { CheckContext } from "@/types/network.js";
|
||||
export interface IStatusProvider<T> {
|
||||
type: string;
|
||||
supports: (type: MonitorType) => boolean;
|
||||
handle(monitor: Monitor): Promise<MonitorStatusResponse<T>>;
|
||||
handle(monitor: Monitor, ctx?: CheckContext): Promise<MonitorStatusResponse<T>>;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { Monitor } from "@/domain/monitors/monitor.type.js";
|
||||
import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js";
|
||||
import { ILogger } from "@/utils/logger.js";
|
||||
import { Proxy } from "@/domain/proxies/proxy.type.js";
|
||||
|
||||
export interface IProxyResolver {
|
||||
resolve(monitor: Monitor): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
const SERVICE_NAME = "ProxyResolver";
|
||||
|
||||
export const buildProxyUrl = (proxy: Proxy): string => {
|
||||
const { protocol, host, port, username, password } = proxy;
|
||||
let credentials = "";
|
||||
if (username) {
|
||||
credentials = password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : `${encodeURIComponent(username)}@`;
|
||||
}
|
||||
return `${protocol}://${credentials}${host}:${port}`;
|
||||
};
|
||||
|
||||
export class ProxyResolver implements IProxyResolver {
|
||||
static SERVICE_NAME = SERVICE_NAME;
|
||||
constructor(
|
||||
private proxiesRepository: IProxiesRepository,
|
||||
private settingsService: ISettingsService,
|
||||
private logger: ILogger,
|
||||
private ttlMs = 60_000
|
||||
) {}
|
||||
|
||||
private cache = new Map<string, { value: Promise<unknown>; expiresAt: number }>();
|
||||
|
||||
private getCached<T>(key: string, fetch: () => Promise<T>): Promise<T> {
|
||||
const hit = this.cache.get(key);
|
||||
if (hit && hit.expiresAt > Date.now()) {
|
||||
return hit.value as Promise<T>;
|
||||
}
|
||||
// Cache the in flight promise so concurrent misses share one fetch
|
||||
const value = fetch();
|
||||
this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
||||
// Don't negative cache rejections, evict so the next resolve retries
|
||||
value.catch(() => this.cache.delete(key));
|
||||
return value;
|
||||
}
|
||||
|
||||
private warn(message: string, monitor: Monitor, error?: unknown) {
|
||||
this.logger.warn({
|
||||
message,
|
||||
service: SERVICE_NAME,
|
||||
method: "resolve",
|
||||
details: {
|
||||
monitorId: monitor.id,
|
||||
proxyMode: monitor.proxyMode,
|
||||
proxyId: monitor.proxyId,
|
||||
...(error instanceof Error ? { error: error.message } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveCustom = async (monitor: Monitor): Promise<string | undefined> => {
|
||||
const { proxyId, teamId } = monitor;
|
||||
if (!proxyId) {
|
||||
this.warn("Proxy mode is custom, but proxyId is missing, connecting direct", monitor);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const proxy = await this.getCached(`proxy:${proxyId}:${teamId}`, () => this.proxiesRepository.findByIdOrNull(proxyId, teamId));
|
||||
if (!proxy) {
|
||||
this.warn("Proxy not found, connecting direct", monitor);
|
||||
return undefined;
|
||||
}
|
||||
return buildProxyUrl(proxy);
|
||||
};
|
||||
|
||||
private resolveInherit = async (monitor: Monitor): Promise<string | undefined> => {
|
||||
const settings = await this.getCached("settings", () => this.settingsService.getDBSettings());
|
||||
if (!settings.globalProxyEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
const globalProxyId = settings.globalProxyId;
|
||||
if (!globalProxyId) {
|
||||
return undefined;
|
||||
}
|
||||
const proxy = await this.getCached(`proxy:${globalProxyId}`, () => this.proxiesRepository.findByIdOrNull(globalProxyId));
|
||||
if (!proxy) {
|
||||
this.warn("Global proxy references a proxy that no longer exists, connecting direct", monitor);
|
||||
return undefined;
|
||||
}
|
||||
return buildProxyUrl(proxy);
|
||||
};
|
||||
|
||||
async resolve(monitor: Monitor): Promise<string | undefined> {
|
||||
if (monitor.type !== "http") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
switch (monitor.proxyMode) {
|
||||
case "none":
|
||||
return undefined;
|
||||
case "custom":
|
||||
return await this.resolveCustom(monitor);
|
||||
default:
|
||||
// "inherit" is the default mode
|
||||
return await this.resolveInherit(monitor);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.warn("Proxy resolution failed, connecting direct", monitor, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
|
||||
import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js";
|
||||
import type { AxiosStatic } from "axios";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { NETWORK_ERROR } from "@/types/network.js";
|
||||
@@ -8,7 +8,7 @@ import { IStatusProvider } from "./network/IStatusProvider.js";
|
||||
const SERVICE_NAME = "NetworkService";
|
||||
|
||||
export interface INetworkService {
|
||||
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
requestWebhook(
|
||||
type: string,
|
||||
url: string,
|
||||
@@ -39,12 +39,15 @@ export class NetworkService implements INetworkService {
|
||||
}
|
||||
|
||||
// Main entry point
|
||||
async requestStatus<T extends MonitorType>(monitor: Monitor & { type: T }): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
|
||||
async requestStatus<T extends MonitorType>(
|
||||
monitor: Monitor & { type: T },
|
||||
ctx?: CheckContext
|
||||
): Promise<MonitorStatusResponse<MonitorPayloadMap[T]>> {
|
||||
const provider = this.providers.find((p) => p.supports(monitor.type));
|
||||
if (!provider) {
|
||||
return this.handleUnsupportedType(monitor.type) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
}
|
||||
return provider.handle(monitor) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
return provider.handle(monitor, ctx) as Promise<MonitorStatusResponse<MonitorPayloadMap[T]>>;
|
||||
}
|
||||
|
||||
private async handleUnsupportedType(type: string): Promise<MonitorStatusResponse> {
|
||||
|
||||
@@ -152,3 +152,7 @@ export type StatusChangeResult = {
|
||||
};
|
||||
|
||||
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
|
||||
|
||||
export type CheckContext = {
|
||||
proxyUrl?: string;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { ICheckService } from "@/domain/checks/check.service.js";
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js";
|
||||
import { isWindowActive } from "@/utils/maintenanceWindow.js";
|
||||
import { IProxyResolver } from "@/service/network/ProxyResolver.js";
|
||||
|
||||
export interface ICheckProducer {
|
||||
produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>;
|
||||
@@ -22,6 +23,7 @@ export class CheckProducer implements ICheckProducer {
|
||||
private maintenanceWindowsRepository: IMaintenanceWindowsRepository,
|
||||
private checkService: ICheckService,
|
||||
private networkService: INetworkService,
|
||||
private proxyResolver: IProxyResolver,
|
||||
private bufferService: IBufferService,
|
||||
private logger: ILogger
|
||||
) {}
|
||||
@@ -56,7 +58,8 @@ export class CheckProducer implements ICheckProducer {
|
||||
}
|
||||
|
||||
// Step 1b: Acquire status
|
||||
const status = await this.networkService.requestStatus(monitor);
|
||||
const proxyUrl = await this.proxyResolver.resolve(monitor);
|
||||
const status = await this.networkService.requestStatus(monitor, { proxyUrl });
|
||||
if (!status) {
|
||||
throw new Error("No network response");
|
||||
}
|
||||
|
||||
@@ -127,12 +127,14 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
};
|
||||
|
||||
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
|
||||
const proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const checkProducer = new CheckProducer(
|
||||
monitorsRepo as any,
|
||||
maintenanceWindowsRepo as any,
|
||||
checkService as any,
|
||||
networkService as any,
|
||||
proxyResolver as any,
|
||||
bufferStub as any,
|
||||
logger
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from "hpagent";
|
||||
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
||||
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
|
||||
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts";
|
||||
@@ -494,4 +495,152 @@ describe("HttpProvider", () => {
|
||||
expect(advancedMatcher.validate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Proxy support ────────────────────────────────────────────────────
|
||||
|
||||
describe("proxy support", () => {
|
||||
const PROXY_URL = "http://proxy.example.com:8080";
|
||||
|
||||
const gotOptions = (callIndex = 0) => mockGot.mock.calls[callIndex]?.[1] as any;
|
||||
|
||||
const makeConnectionError = (code: string, message = `connect ${code} 10.0.0.1:8080`) => {
|
||||
const err = new RequestError(message);
|
||||
(err as any).code = code;
|
||||
return err;
|
||||
};
|
||||
|
||||
it("uses hpagent proxy agents when ctx.proxyUrl is set", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(gotOptions().agent.http).toBeInstanceOf(HttpProxyAgent);
|
||||
expect(gotOptions().agent.https).toBeInstanceOf(HttpsProxyAgent);
|
||||
});
|
||||
|
||||
it("uses the shared agents when ctx has no proxyUrl", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor());
|
||||
await provider.handle(makeMonitor(), {});
|
||||
|
||||
expect(gotOptions(0).agent.http).not.toBeInstanceOf(HttpProxyAgent);
|
||||
expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http);
|
||||
expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https);
|
||||
});
|
||||
|
||||
it("reuses the cached agent pair across checks with the same proxyUrl", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http);
|
||||
expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https);
|
||||
});
|
||||
|
||||
it("builds distinct agent pairs for distinct proxyUrls", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
await provider.handle(makeMonitor(), { proxyUrl: "http://other.example.com:3128" });
|
||||
|
||||
expect(gotOptions(1).agent.http).not.toBe(gotOptions(0).agent.http);
|
||||
});
|
||||
|
||||
it("builds distinct pairs per ignoreTlsErrors and sets rejectUnauthorized accordingly", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: false }), { proxyUrl: PROXY_URL });
|
||||
await provider.handle(makeMonitor({ ignoreTlsErrors: true }), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(gotOptions(1).agent.https).not.toBe(gotOptions(0).agent.https);
|
||||
expect(gotOptions(0).agent.https.options.rejectUnauthorized).toBe(true);
|
||||
expect(gotOptions(1).agent.https.options.rejectUnauthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("evicts and destroys the least-recently-used pair past the cache cap", async () => {
|
||||
mockGot.mockResolvedValue(makeGotResponse());
|
||||
const { provider } = createProvider();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" });
|
||||
const first = gotOptions(0).agent;
|
||||
const httpDestroy = jest.spyOn(first.http, "destroy");
|
||||
const httpsDestroy = jest.spyOn(first.https, "destroy");
|
||||
|
||||
for (let i = 1; i <= 49; i++) {
|
||||
await provider.handle(makeMonitor(), { proxyUrl: `http://proxy${i}.example.com:8080` });
|
||||
}
|
||||
expect(httpDestroy).not.toHaveBeenCalled();
|
||||
|
||||
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy50.example.com:8080" });
|
||||
|
||||
expect(httpDestroy).toHaveBeenCalled();
|
||||
expect(httpsDestroy).toHaveBeenCalled();
|
||||
// The evicted pair is gone — the same proxyUrl now gets a fresh pair
|
||||
await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" });
|
||||
expect(gotOptions(51).agent.http).not.toBe(first.http);
|
||||
});
|
||||
|
||||
it("names the proxy in the message when the proxy connection fails", async () => {
|
||||
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(NETWORK_ERROR);
|
||||
expect(result.message).toBe("Proxy connection failed (proxy.example.com:8080): connect ECONNREFUSED 10.0.0.1:8080");
|
||||
});
|
||||
|
||||
it("does not attribute timeouts to the proxy", async () => {
|
||||
mockGot.mockRejectedValue(makeConnectionError("ETIMEDOUT", "Timeout awaiting 'request' for 30000ms"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.message).toBe("Timeout awaiting 'request' for 30000ms");
|
||||
});
|
||||
|
||||
it("leaves connection-error messages unprefixed for direct checks", async () => {
|
||||
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.message).toBe("connect ECONNREFUSED 10.0.0.1:8080");
|
||||
});
|
||||
|
||||
it("never leaks proxy credentials into the message", async () => {
|
||||
mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED"));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor(), { proxyUrl: "http://bob:sekret@proxy.example.com:8080" });
|
||||
|
||||
expect(result.message).toContain("proxy.example.com:8080");
|
||||
expect(result.message).not.toContain("bob");
|
||||
expect(result.message).not.toContain("sekret");
|
||||
});
|
||||
|
||||
it("does not prefix errors that carry a response (target failure through a healthy proxy)", async () => {
|
||||
const err = new RequestError("Response code 500 (Internal Server Error)");
|
||||
(err as any).code = "ECONNRESET";
|
||||
(err as any).response = { statusCode: 500, body: "", headers: {} };
|
||||
(err as any).timings = { phases: { total: 25 } };
|
||||
mockGot.mockRejectedValue(err);
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL });
|
||||
|
||||
expect(result.status).toBe(false);
|
||||
expect(result.code).toBe(500);
|
||||
expect(result.message).toBe("Response code 500 (Internal Server Error)");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ const createProducer = (overrides?: Record<string, any>) => {
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
|
||||
checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) },
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
|
||||
proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) },
|
||||
buffer: { addToBuffer: jest.fn() },
|
||||
...overrides,
|
||||
};
|
||||
@@ -34,6 +35,7 @@ const createProducer = (overrides?: Record<string, any>) => {
|
||||
defaults.maintenanceWindowsRepository as any,
|
||||
defaults.checkService as any,
|
||||
defaults.networkService as any,
|
||||
defaults.proxyResolver as any,
|
||||
defaults.buffer as any,
|
||||
defaults.logger as any
|
||||
);
|
||||
@@ -82,6 +84,29 @@ describe("CheckProducer", () => {
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── proxy resolution ──────────────────────────────────────────────────────
|
||||
|
||||
it("passes the resolved proxy url into requestStatus", async () => {
|
||||
const { producer, defaults } = createProducer({
|
||||
proxyResolver: { resolve: jest.fn().mockResolvedValue("http://proxy.example.com:8080") },
|
||||
});
|
||||
const monitor = makeMonitor();
|
||||
|
||||
await producer.produce(monitor);
|
||||
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" });
|
||||
});
|
||||
|
||||
it("still produces a check when the resolver returns undefined", async () => {
|
||||
const { producer, defaults } = createProducer();
|
||||
const monitor = makeMonitor();
|
||||
|
||||
const result = await producer.produce(monitor);
|
||||
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined });
|
||||
expect(result).toEqual({ status: expect.objectContaining({ monitorId: "m1" }), check: { id: "check-1" } });
|
||||
});
|
||||
|
||||
// ── acquire / record ──────────────────────────────────────────────────────
|
||||
|
||||
it("throws when the network response is null", async () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("NetworkService", () => {
|
||||
const result = await service.requestStatus(monitor);
|
||||
|
||||
expect(provider.supports).toHaveBeenCalledWith("http");
|
||||
expect(provider.handle).toHaveBeenCalledWith(monitor);
|
||||
expect(provider.handle).toHaveBeenCalledWith(monitor, undefined);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
monitorId: "mon-1",
|
||||
@@ -73,7 +73,18 @@ describe("NetworkService", () => {
|
||||
await service.requestStatus(monitor);
|
||||
|
||||
expect(httpProvider.handle).not.toHaveBeenCalled();
|
||||
expect(pingProvider.handle).toHaveBeenCalledWith(monitor);
|
||||
expect(pingProvider.handle).toHaveBeenCalledWith(monitor, undefined);
|
||||
});
|
||||
|
||||
it("forwards the check context to the provider", async () => {
|
||||
const provider = createMockProvider("http");
|
||||
const { service } = createService([provider]);
|
||||
const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" };
|
||||
const ctx = { proxyUrl: "http://proxy.example.com:8080" };
|
||||
|
||||
await service.requestStatus(monitor, ctx);
|
||||
|
||||
expect(provider.handle).toHaveBeenCalledWith(monitor, ctx);
|
||||
});
|
||||
|
||||
it("returns unsupported-type response when no provider matches", async () => {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { ProxyResolver, buildProxyUrl } from "../../../src/service/network/ProxyResolver.ts";
|
||||
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
|
||||
import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "m1",
|
||||
teamId: "team",
|
||||
type: "http",
|
||||
proxyMode: "inherit",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeProxy = (overrides?: Partial<Proxy>): Proxy =>
|
||||
({
|
||||
id: "p1",
|
||||
teamId: "team",
|
||||
name: "corp",
|
||||
protocol: "http",
|
||||
host: "proxy.example.com",
|
||||
port: 8080,
|
||||
...overrides,
|
||||
}) as Proxy;
|
||||
|
||||
const createResolver = (overrides?: Record<string, any>) => {
|
||||
const defaults = {
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(makeProxy()) },
|
||||
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: "p1" }) },
|
||||
logger: createMockLogger(),
|
||||
ttlMs: 60_000,
|
||||
...overrides,
|
||||
};
|
||||
const resolver = new ProxyResolver(defaults.proxiesRepository as any, defaults.settingsService as any, defaults.logger as any, defaults.ttlMs);
|
||||
return { resolver, defaults };
|
||||
};
|
||||
|
||||
describe("ProxyResolver", () => {
|
||||
// ── gates ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined for non-http monitors without touching repo or settings", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ type: "port", proxyMode: "custom", proxyId: "p1" } as Partial<Monitor>));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
|
||||
expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined for proxyMode 'none' without calling settings", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "none" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── custom mode ───────────────────────────────────────────────────────────
|
||||
|
||||
it("resolves a custom proxy with a team-scoped lookup", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
|
||||
expect(result).toBe("http://proxy.example.com:8080");
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1", "team");
|
||||
});
|
||||
|
||||
it("returns undefined and warns when the custom proxy no longer exists", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined and warns when proxyMode is custom but proxyId is missing, without a repo call", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: undefined }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── inherit mode ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined when the global proxy is disabled, without a repo call", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: false, globalProxyId: "p1" }) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the global proxy with an unscoped lookup when enabled", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBe("http://proxy.example.com:8080");
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
|
||||
it("returns undefined when the global proxy is enabled but globalProxyId is null, without a repo call", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: null }) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined and warns when the global proxy references a proxy that no longer exists", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats an unknown proxyMode as inherit", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "garbage" as Monitor["proxyMode"] }));
|
||||
|
||||
expect(result).toBe("http://proxy.example.com:8080");
|
||||
expect(defaults.settingsService.getDBSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── never throws ──────────────────────────────────────────────────────────
|
||||
|
||||
it("returns undefined and warns when the repository throws", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockRejectedValue(new Error("db down")) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined and warns when the settings service throws", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
settingsService: { getDBSettings: jest.fn<() => Promise<any>>().mockRejectedValue(new Error("db down")) },
|
||||
});
|
||||
|
||||
const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(defaults.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── caching ───────────────────────────────────────────────────────────────
|
||||
|
||||
it("serves a second resolve within the TTL from cache", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1);
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refetches after the TTL expires", async () => {
|
||||
const { resolver, defaults } = createResolver({ ttlMs: 0 });
|
||||
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(2);
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("negative-caches a dangling ref, costing one repo call per TTL window", async () => {
|
||||
const { resolver, defaults } = createResolver({
|
||||
proxiesRepository: { findByIdOrNull: jest.fn<() => Promise<Proxy | null>>().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" }));
|
||||
await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" }));
|
||||
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shares one in-flight fetch between concurrent resolves", async () => {
|
||||
const { resolver, defaults } = createResolver();
|
||||
|
||||
await Promise.all([
|
||||
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
|
||||
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
|
||||
resolver.resolve(makeMonitor({ proxyMode: "inherit" })),
|
||||
]);
|
||||
|
||||
expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1);
|
||||
expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache a rejected fetch — the next resolve retries", async () => {
|
||||
const findByIdOrNull = jest.fn<() => Promise<Proxy | null>>().mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce(makeProxy());
|
||||
const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } });
|
||||
|
||||
const first = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
const second = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
|
||||
expect(first).toBeUndefined();
|
||||
expect(second).toBe("http://proxy.example.com:8080");
|
||||
expect(findByIdOrNull).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not let a scoped miss poison the unscoped global lookup for the same proxy id", async () => {
|
||||
const findByIdOrNull = jest.fn<(proxyId: string, teamId?: string) => Promise<Proxy | null>>(async (_proxyId, teamId) =>
|
||||
teamId ? null : makeProxy()
|
||||
);
|
||||
const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } });
|
||||
|
||||
const customResult = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" }));
|
||||
const inheritResult = await resolver.resolve(makeMonitor({ proxyMode: "inherit" }));
|
||||
|
||||
expect(customResult).toBeUndefined();
|
||||
expect(inheritResult).toBe("http://proxy.example.com:8080");
|
||||
expect(findByIdOrNull).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProxyUrl", () => {
|
||||
it("builds a URL without credentials when there is no username", () => {
|
||||
expect(buildProxyUrl(makeProxy())).toBe("http://proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("builds a URL with username and password", () => {
|
||||
expect(buildProxyUrl(makeProxy({ username: "user", password: "pass" }))).toBe("http://user:pass@proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("percent-encodes credentials", () => {
|
||||
expect(buildProxyUrl(makeProxy({ username: "user@x", password: "p@ss" }))).toBe("http://user%40x:p%40ss@proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("builds a URL with a username only, without a colon", () => {
|
||||
expect(buildProxyUrl(makeProxy({ username: "user" }))).toBe("http://user@proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("emits no credentials for a password without a username", () => {
|
||||
expect(buildProxyUrl(makeProxy({ password: "pass" }))).toBe("http://proxy.example.com:8080");
|
||||
});
|
||||
|
||||
it("uses the https scheme without a trailing slash", () => {
|
||||
expect(buildProxyUrl(makeProxy({ protocol: "https" }))).toBe("https://proxy.example.com:8080");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user