Files
2026-09-25 11:39:16 -07:00

265 lines
11 KiB
TypeScript

import { describe, expect, it, jest } from "@jest/globals";
import { ProxyResolver, buildProxyUrl } from "../../../src/service/networkProviders/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: { getCachedDBSettings: 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.getCachedDBSettings).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.getCachedDBSettings).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: { getCachedDBSettings: 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: { getCachedDBSettings: 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.getCachedDBSettings).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: { getCachedDBSettings: 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.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.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.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");
});
});