Merge pull request #3860 from bluewave-labs/feat/proxy-resolver

feat: proxy resolver
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-08-21 12:34:22 -07:00
commit 41859f241a
16 files changed
+677 -20

No files matched your search

+10
View File
@@ -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",
+1
View File
@@ -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",
+12 -2
View File
@@ -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);
+56 -8
View File
@@ -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>>;
}
+111
View File
@@ -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;
}
}
}
+7 -4
View File
@@ -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> {
+4
View File
@@ -152,3 +152,7 @@ export type StatusChangeResult = {
};
export type MonitorStatusResponseOverrides<T> = Partial<Omit<MonitorStatusResponse<T>, "monitorId" | "teamId" | "type">>;
export type CheckContext = {
proxyUrl?: string;
};
+4 -1
View File
@@ -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");
});
});