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"; // ── Mocks ──────────────────────────────────────────────────────────────────── jest.unstable_mockModule("cacheable-lookup", () => ({ default: jest.fn().mockImplementation(() => ({})), })); const mockGot = jest.fn(); // got instance returned by got.extend() mockGot.mockImplementation(() => Promise.resolve()); (mockGot as any).extend = jest.fn().mockReturnValue(mockGot); jest.unstable_mockModule("got", () => ({ type: { Got: {} }, HTTPError: class HTTPError extends Error { response: any; timings: any; constructor(msg: string, response?: any, timings?: any) { super(msg); this.name = "HTTPError"; this.response = response; this.timings = timings; } }, RequestError: class RequestError extends Error { response: any; timings: any; constructor(msg: string, response?: any, timings?: any) { super(msg); this.name = "RequestError"; this.response = response; this.timings = timings; } }, })); const { HttpProvider } = await import("../../../../src/service/networkProviders/HttpProvider.ts"); const gotModule = await import("got"); const { HTTPError, RequestError } = gotModule; // ── Helpers ────────────────────────────────────────────────────────────────── const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", teamId: "team-1", type: "http", url: "https://example.com", secret: undefined, jsonPath: undefined, ignoreTlsErrors: false, useAdvancedMatching: false, matchMethod: undefined, expectedValue: undefined, customUpCodes: [], ...overrides, }) as Monitor; const createMockMatcher = (result?: { ok: boolean; message: string; extracted?: unknown }) => ({ validate: jest.fn().mockReturnValue(result ?? { ok: true, message: "Success" }), }); const makeGotResponse = (overrides?: Record) => ({ ok: true, statusCode: 200, statusMessage: "OK", headers: { "content-type": "text/html" }, body: "", timings: { phases: { firstByte: 100, total: 120 } }, ...overrides, }); const createProvider = (matcher?: any) => { const advancedMatcher = matcher ?? createMockMatcher(); const provider = new HttpProvider(mockGot as any, advancedMatcher); return { provider, advancedMatcher }; }; // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("HttpProvider", { create: () => { mockGot.mockResolvedValue(makeGotResponse()); return createProvider().provider; }, supportedType: "http", unsupportedType: "ping", makeMonitor: () => makeMonitor(), }); // ── Tests ──────────────────────────────────────────────────────────────────── describe("HttpProvider", () => { // ── Success paths ──────────────────────────────────────────────────── describe("success responses", () => { it("returns success for a standard HTML response", async () => { mockGot.mockResolvedValue(makeGotResponse()); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result).toEqual( expect.objectContaining({ monitorId: "mon-1", teamId: "team-1", type: "http", status: true, code: 200, message: "OK", responseTime: 120, }) ); }); it("parses JSON body when content-type is application/json", async () => { mockGot.mockResolvedValue( makeGotResponse({ headers: { "content-type": "application/json" }, body: '{"status":"ok"}', }) ); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.payload).toEqual({ status: "ok" }); }); it("returns raw body when JSON parsing fails", async () => { mockGot.mockResolvedValue( makeGotResponse({ headers: { "content-type": "application/json" }, body: "not-json", }) ); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.payload).toBe("not-json"); }); it("returns raw body for non-JSON content", async () => { mockGot.mockResolvedValue(makeGotResponse({ body: "test" })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.payload).toBe("test"); }); it("passes Authorization header when secret is set", async () => { mockGot.mockResolvedValue(makeGotResponse()); const { provider } = createProvider(); await provider.handle(makeMonitor({ secret: "my-token" })); expect(mockGot).toHaveBeenCalledWith( "https://example.com", expect.objectContaining({ headers: { Authorization: "Bearer my-token" }, }) ); }); it("passes undefined headers when no secret", async () => { mockGot.mockResolvedValue(makeGotResponse()); const { provider } = createProvider(); await provider.handle(makeMonitor({ secret: undefined })); expect(mockGot).toHaveBeenCalledWith( "https://example.com", expect.objectContaining({ headers: undefined, }) ); }); it("defaults responseTime to 0 when timings.phases.total is undefined", async () => { mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.responseTime).toBe(0); }); it("defaults statusMessage to 'OK' when undefined", async () => { mockGot.mockResolvedValue(makeGotResponse({ statusMessage: undefined })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.message).toBe("OK"); }); it("uses empty string for content-type when header is missing", async () => { mockGot.mockResolvedValue(makeGotResponse({ headers: {} })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); // Should treat as non-JSON expect(result.payload).toBe(""); }); }); // ── jsonPath + non-JSON response ───────────────────────────────────── describe("jsonPath validation", () => { it("returns failure when jsonPath is set but response is not JSON", async () => { mockGot.mockResolvedValue(makeGotResponse({ headers: { "content-type": "text/html" } })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ jsonPath: "status" })); expect(result.status).toBe(false); expect(result.message).toBe("Response is not JSON"); }); it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => { mockGot.mockResolvedValue( makeGotResponse({ headers: { "content-type": "text/html" }, timings: { phases: { total: undefined } }, }) ); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ jsonPath: "status" })); expect(result.responseTime).toBe(0); }); }); // ── AdvancedMatcher integration ────────────────────────────────────── describe("advanced matching", () => { it("uses matcher result for status and message", async () => { mockGot.mockResolvedValue(makeGotResponse()); const matcher = createMockMatcher({ ok: false, message: "Mismatch", extracted: "value" }); const { provider } = createProvider(matcher); const result = await provider.handle(makeMonitor({ useAdvancedMatching: true })); expect(result.status).toBe(false); expect(result.message).toBe("Mismatch"); expect(result.extracted).toBe("value"); }); it("sets status to false when status code is non-2xx even if matcher passes", async () => { mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 })); const matcher = createMockMatcher({ ok: true, message: "Success" }); const { provider } = createProvider(matcher); const result = await provider.handle(makeMonitor()); expect(result.status).toBe(false); }); }); // ── Error handling ─────────────────────────────────────────────────── describe("error handling", () => { it("handles HTTPError with response and timings", async () => { const err = new HTTPError("Not Found"); (err as any).response = { statusCode: 404 }; (err as any).timings = { phases: { total: 50 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result).toEqual( expect.objectContaining({ status: false, code: 404, message: "Not Found", responseTime: 50, }) ); }); it("handles RequestError with response and timings", async () => { const err = new RequestError("ECONNREFUSED"); (err as any).response = { statusCode: undefined }; (err as any).timings = { phases: { total: undefined } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.code).toBe(NETWORK_ERROR); expect(result.responseTime).toBe(0); }); it("handles HTTPError without response (defaults to NETWORK_ERROR)", async () => { const err = new HTTPError("Timeout"); (err as any).response = undefined; (err as any).timings = undefined; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.code).toBe(NETWORK_ERROR); expect(result.responseTime).toBe(0); }); it("handles generic Error (non-HTTPError/RequestError)", async () => { mockGot.mockRejectedValue(new Error("DNS lookup failed")); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result).toEqual( expect.objectContaining({ status: false, code: NETWORK_ERROR, message: "DNS lookup failed", responseTime: 0, }) ); }); it("handles non-Error thrown values", async () => { mockGot.mockRejectedValue("string error"); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.message).toBe("string error"); expect(result.code).toBe(NETWORK_ERROR); }); it("throws when url is missing", async () => { const { provider } = createProvider(); await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor"); }); }); // ── customUpCodes ──────────────────────────────────────────────────── describe("customUpCodes", () => { it("returns status true when HTTPError status code is in customUpCodes", async () => { const err = new HTTPError("Unauthorized"); (err as any).response = { statusCode: 401 }; (err as any).timings = { phases: { total: 30 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ customUpCodes: [401] })); expect(result.status).toBe(true); expect(result.code).toBe(401); }); it("returns status false when HTTPError status code is not in customUpCodes", async () => { const err = new HTTPError("Internal Server Error"); (err as any).response = { statusCode: 500 }; (err as any).timings = { phases: { total: 40 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ customUpCodes: [401, 403] })); expect(result.status).toBe(false); expect(result.code).toBe(500); }); it("returns status false when customUpCodes is empty", async () => { const err = new HTTPError("Unauthorized"); (err as any).response = { statusCode: 401 }; (err as any).timings = { phases: { total: 25 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ customUpCodes: [] })); expect(result.status).toBe(false); expect(result.code).toBe(401); }); it("treats non-2xx success response as up when status code is in customUpCodes", async () => { mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301, statusMessage: "Moved Permanently" })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ customUpCodes: [301] })); expect(result.status).toBe(true); expect(result.code).toBe(301); }); it("does not override matcher failure even when status code is in customUpCodes", async () => { mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 })); const matcher = createMockMatcher({ ok: false, message: "Body mismatch" }); const { provider } = createProvider(matcher); const result = await provider.handle(makeMonitor({ customUpCodes: [301] })); expect(result.status).toBe(false); expect(result.message).toBe("Body mismatch"); }); it("does not override matcher failure on HTTPError when status code is in customUpCodes", async () => { const err = new HTTPError("Unauthorized"); (err as any).response = { statusCode: 401, body: '{"status":"down"}', headers: { "content-type": "application/json" }, }; (err as any).timings = { phases: { total: 30 } }; mockGot.mockRejectedValue(err); const matcher = createMockMatcher({ ok: false, message: "Body mismatch", extracted: "down", }); const { provider } = createProvider(matcher); const result = await provider.handle(makeMonitor({ customUpCodes: [401], useAdvancedMatching: true })); expect(result.status).toBe(false); expect(result.message).toBe("Body mismatch"); expect(result.payload).toEqual({ status: "down" }); expect(result.extracted).toBe("down"); }); }); // ── HTTP method (GET / HEAD) ───────────────────────────────────────── describe("request method", () => { it("passes GET to got when method is GET", async () => { mockGot.mockResolvedValue(makeGotResponse()); const { provider } = createProvider(); await provider.handle(makeMonitor({ method: "GET" })); expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "GET" })); }); it("passes HEAD to got when method is HEAD", async () => { mockGot.mockResolvedValue(makeGotResponse({ body: "" })); const { provider } = createProvider(); await provider.handle(makeMonitor({ method: "HEAD" })); expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "HEAD" })); }); it("does not mark a HEAD monitor down on an empty body even with advanced matching on", async () => { mockGot.mockResolvedValue(makeGotResponse({ body: "", headers: {} })); // A matcher that would fail — proving HEAD never reaches it const matcher = createMockMatcher({ ok: false, message: "Extracted value is falsy" }); const { provider, advancedMatcher } = createProvider(matcher); const result = await provider.handle(makeMonitor({ method: "HEAD", useAdvancedMatching: true, jsonPath: "status" })); expect(result.status).toBe(true); expect(advancedMatcher.validate).not.toHaveBeenCalled(); }); it("bases HEAD status on the status code (down when not an up code)", async () => { const err = new HTTPError("Internal Server Error"); (err as any).response = { statusCode: 500, body: "", headers: {} }; (err as any).timings = { phases: { total: 40 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ method: "HEAD" })); expect(result.status).toBe(false); expect(result.code).toBe(500); }); it("respects customUpCodes for HEAD requests without consulting the matcher", async () => { const err = new HTTPError("Unauthorized"); (err as any).response = { statusCode: 401, body: "", headers: {} }; (err as any).timings = { phases: { total: 30 } }; mockGot.mockRejectedValue(err); const { provider, advancedMatcher } = createProvider(); const result = await provider.handle(makeMonitor({ method: "HEAD", customUpCodes: [401], useAdvancedMatching: true })); expect(result.status).toBe(true); expect(result.code).toBe(401); 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)"); }); }); // ── Peer reachability ──────────────────────────────────────────────────── // Any HTTP status proves the target answered. Without one, only an errno that requires the peer's own stack // to have replied counts — and never through a proxy, where the refusal may be the proxy's rather than the // target's, which is the instance's own egress path failing. describe("peer reachability", () => { it("reports the peer as having answered on a successful response", async () => { mockGot.mockResolvedValue(makeGotResponse()); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.peerResponded).toBe(true); }); it("reports the peer as having answered for a failing status code", async () => { const err = new HTTPError("Not Found", { statusCode: 404, body: "", headers: {} }, { phases: { total: 12 } }); mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.status).toBe(false); expect(result.code).toBe(404); expect(result.peerResponded).toBe(true); }); it.each([["ECONNREFUSED"], ["ECONNRESET"], ["CERT_HAS_EXPIRED"], ["DEPTH_ZERO_SELF_SIGNED_CERT"]])( "reports the peer as having answered for %s with no response", async (code) => { const err = new RequestError("request failed"); (err as any).code = code; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.code).toBe(NETWORK_ERROR); expect(result.peerResponded).toBe(true); } ); it.each([["ETIMEDOUT"], ["ENOTFOUND"], ["EAI_AGAIN"], ["ENETUNREACH"]])("reports the peer as silent for %s", async (code) => { const err = new RequestError("request failed"); (err as any).code = code; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.peerResponded).toBe(false); }); it("reports the peer as silent when a proxy refuses the connection", async () => { const err = new RequestError("connect ECONNREFUSED"); (err as any).code = "ECONNREFUSED"; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor(), { proxyUrl: "http://proxy.example.com:8080" }); // The refusal came from the proxy, which is part of this instance's route out. expect(result.peerResponded).toBe(false); }); it("reports the peer as having answered through a proxy when a status code came back", 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: "http://proxy.example.com:8080" }); expect(result.peerResponded).toBe(true); }); it("reports the peer as silent for a generic error carrying no code", async () => { mockGot.mockRejectedValue(new Error("something broke")); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.peerResponded).toBe(false); }); }); });