Files
homepage/src/utils/proxy/http.test.js

442 lines
14 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const { state, cache, logger, dns, net, cookieJar } = vi.hoisted(() => ({
state: {
response: {
statusCode: 200,
headers: { "content-type": "application/json" },
body: Buffer.from(""),
},
error: null,
lastAgent: null,
lastAgentOptions: null,
lastRequestParams: null,
lastWrittenBody: null,
},
cache: {
get: vi.fn(),
put: vi.fn(),
},
logger: {
debug: vi.fn(),
error: vi.fn(),
},
dns: {
lookup: vi.fn(),
resolve4: vi.fn(),
resolve6: vi.fn(),
},
net: {
isIP: vi.fn(),
},
cookieJar: {
addCookieToJar: vi.fn(),
setCookieHeader: vi.fn(),
},
}));
vi.mock("node:dns", () => ({
default: dns,
}));
vi.mock("node:net", () => ({
default: net,
}));
vi.mock("follow-redirects", async () => {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
function Agent(opts) {
this.opts = opts;
}
function makeRequest() {
return (url, params, cb) => {
const req = new EventEmitter();
state.lastRequestParams = params;
state.lastWrittenBody = null;
req.write = vi.fn((chunk) => {
state.lastWrittenBody = chunk;
});
req.end = vi.fn(() => {
state.lastAgent = params?.agent ?? null;
state.lastAgentOptions = params?.agent?.opts ?? null;
if (state.error) {
req.emit("error", state.error);
return;
}
const res = new Readable({
read() {
this.push(state.response.body);
this.push(null);
},
});
res.statusCode = state.response.statusCode;
res.headers = state.response.headers;
cb(res);
});
return req;
};
}
return {
http: { request: makeRequest(), Agent },
https: { request: makeRequest(), Agent },
};
});
vi.mock("memory-cache", () => ({
default: cache,
}));
vi.mock("./cookie-jar", () => cookieJar);
vi.mock("utils/logger", () => ({
default: () => logger,
}));
describe("utils/proxy/http cachedRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
state.error = null;
state.response = {
statusCode: 200,
headers: { "content-type": "application/json" },
body: Buffer.from(""),
};
state.lastAgent = null;
state.lastAgentOptions = null;
state.lastRequestParams = null;
state.lastWrittenBody = null;
vi.resetModules();
});
it("returns cached values without calling httpProxy", async () => {
cache.get.mockReturnValueOnce({ ok: true });
const httpMod = await import("./http");
const spy = vi.spyOn(httpMod, "httpProxy");
const data = await httpMod.cachedRequest("http://example.com");
expect(data).toEqual({ ok: true });
expect(spy).not.toHaveBeenCalled();
});
it("parses json buffer responses and caches the result", async () => {
cache.get.mockReturnValueOnce(null);
state.response.body = Buffer.from('{"a":1}');
const httpMod = await import("./http");
const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua");
expect(data).toEqual({ a: 1 });
expect(cache.put).toHaveBeenCalledWith("http://example.com/data", { a: 1 }, 1 * 1000 * 60);
});
it("falls back to string when cachedRequest cannot parse json", async () => {
cache.get.mockReturnValueOnce(null);
state.response.body = Buffer.from("not-json");
const httpMod = await import("./http");
const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua");
expect(data).toBe("not-json");
expect(logger.debug).toHaveBeenCalled();
});
});
describe("utils/proxy/http homepageDNSLookupFn", () => {
const getLookupFn = async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com");
expect(state.lastAgentOptions?.lookup).toEqual(expect.any(Function));
return state.lastAgentOptions.lookup;
};
beforeEach(() => {
vi.clearAllMocks();
state.error = null;
state.lastAgentOptions = null;
net.isIP.mockReturnValue(0);
dns.lookup.mockImplementation((hostname, options, cb) => cb(null, "127.0.0.1", 4));
dns.resolve4.mockImplementation((hostname, cb) => cb(null, ["127.0.0.1"]));
dns.resolve6.mockImplementation((hostname, cb) => cb(null, ["::1"]));
vi.resetModules();
});
it("short-circuits when hostname is already an IP (all=false)", async () => {
const lookup = await getLookupFn();
net.isIP.mockReturnValueOnce(4);
const cb = vi.fn();
lookup("1.2.3.4", cb);
expect(dns.lookup).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(null, "1.2.3.4", 4);
});
it("short-circuits when hostname is already an IP (all=true)", async () => {
const lookup = await getLookupFn();
net.isIP.mockReturnValueOnce(6);
const cb = vi.fn();
lookup("::1", { all: true }, cb);
expect(dns.lookup).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(null, [{ address: "::1", family: 6 }]);
});
it("uses dns.lookup when it succeeds (2-argument form)", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(null, "10.0.0.1", 4));
lookup("example.com", cb);
expect(dns.lookup).toHaveBeenCalledWith("example.com", {}, expect.any(Function));
expect(dns.resolve4).not.toHaveBeenCalled();
expect(dns.resolve6).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(null, "10.0.0.1", 4);
});
it("does not fall back for non-ENOTFOUND/EAI_NONAME lookup errors", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const err = Object.assign(new Error("temporary"), { code: "EAI_AGAIN" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(err));
lookup("example.com", { all: true }, cb);
expect(dns.resolve4).not.toHaveBeenCalled();
expect(dns.resolve6).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(err);
});
it("falls back to resolve4 when lookup fails with ENOTFOUND and family=4", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["1.1.1.1"]));
lookup("example.com", { family: 4, all: true }, cb);
expect(dns.resolve4).toHaveBeenCalledWith("example.com", expect.any(Function));
expect(dns.resolve6).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(null, [{ address: "1.1.1.1", family: 4 }]);
expect(logger.debug).toHaveBeenCalledWith("DNS fallback to c-ares resolver succeeded for %s", "example.com");
});
it("falls back to resolve6 when lookup fails with ENOTFOUND and family=6", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["::1"]));
lookup("example.com", 6, cb);
expect(dns.lookup).toHaveBeenCalledWith("example.com", { family: 6 }, expect.any(Function));
expect(dns.resolve4).not.toHaveBeenCalled();
expect(dns.resolve6).toHaveBeenCalledWith("example.com", expect.any(Function));
expect(cb).toHaveBeenCalledWith(null, ["::1"], 6);
});
it("tries resolve4 then resolve6 when lookup fails and no family is specified", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve4.mockImplementationOnce((hostname, resolveCb) =>
resolveCb(Object.assign(new Error("v4 failed"), { code: "EAI_FAIL" })),
);
dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["::1"]));
lookup("example.com", { all: true }, cb);
expect(dns.resolve4).toHaveBeenCalledWith("example.com", expect.any(Function));
expect(dns.resolve6).toHaveBeenCalledWith("example.com", expect.any(Function));
expect(cb).toHaveBeenCalledWith(null, [{ address: "::1", family: 6 }]);
});
it("returns ENOTFOUND when fallback resolver returns no addresses", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, []));
lookup("example.com", { family: 4, all: true }, cb);
const err = cb.mock.calls[0][0];
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("ENOTFOUND");
expect(dns.resolve6).not.toHaveBeenCalled();
});
it("returns resolve error when fallback resolver fails", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
const resolveErr = Object.assign(new Error("resolver down"), { code: "EAI_FAIL" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(resolveErr));
lookup("example.com", { family: 4, all: true }, cb);
expect(cb).toHaveBeenCalledWith(resolveErr);
expect(logger.debug).toHaveBeenCalledWith(
"DNS fallback failed for %s: lookup error=%s, resolve error=%s",
"example.com",
"ENOTFOUND",
"EAI_FAIL",
);
});
});
describe("utils/proxy/http httpProxy", () => {
beforeEach(() => {
vi.clearAllMocks();
state.error = null;
state.response = {
statusCode: 200,
headers: { "content-type": "application/json" },
body: Buffer.from("ok"),
};
state.lastAgent = null;
state.lastAgentOptions = null;
state.lastRequestParams = null;
state.lastWrittenBody = null;
process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = "";
vi.resetModules();
});
it("sets content-length and writes request bodies", async () => {
const httpMod = await import("./http");
const body = "abc";
const [status] = await httpMod.httpProxy("http://example.com", { method: "POST", body, headers: {} });
expect(status).toBe(200);
expect(state.lastRequestParams.headers["content-length"]).toBe(3);
expect(state.lastWrittenBody).toBe(body);
});
it("installs a beforeRedirect hook and updates the cookie jar", async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com");
expect(state.lastRequestParams.beforeRedirect).toEqual(expect.any(Function));
expect(cookieJar.setCookieHeader).toHaveBeenCalled();
expect(cookieJar.addCookieToJar).toHaveBeenCalled();
});
it("updates cookies during redirects via beforeRedirect", async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com");
state.lastRequestParams.beforeRedirect(
{ href: "http://example.com/redirect" },
{ headers: { "set-cookie": ["a=b"] } },
);
expect(cookieJar.addCookieToJar).toHaveBeenCalledWith("http://example.com/redirect", { "set-cookie": ["a=b"] });
expect(cookieJar.setCookieHeader).toHaveBeenCalledWith("http://example.com/redirect", expect.any(Object), {
overwrite: true,
});
});
it("supports gzip-compressed responses", async () => {
const { gzipSync } = await import("node:zlib");
state.response.headers["content-encoding"] = "gzip";
state.response.body = gzipSync(Buffer.from("hello"));
const httpMod = await import("./http");
const [, , data] = await httpMod.httpProxy("http://example.com");
expect(Buffer.from(data).toString()).toBe("hello");
});
it("logs when gzip decoding emits an error", async () => {
const { PassThrough } = await import("node:stream");
vi.doMock("node:zlib", async () => {
const actual = await vi.importActual("node:zlib");
return {
...actual,
createUnzip: () => {
const pt = new PassThrough();
pt.on("pipe", () => {
queueMicrotask(() => {
pt.emit("error", new Error("bad gzip"));
pt.end();
});
});
return pt;
},
};
});
vi.resetModules();
const httpMod = await import("./http");
state.response.headers["content-encoding"] = "gzip";
state.response.body = Buffer.from("hello");
await httpMod.httpProxy("http://example.com");
expect(logger.error).toHaveBeenCalled();
vi.unmock("node:zlib");
});
it("applies strict IPv4 agent options when HOMEPAGE_PROXY_DISABLE_IPV6 is true", async () => {
process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = "true";
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com");
expect(state.lastAgentOptions.keepAlive).toBe(true);
expect(state.lastAgentOptions.family).toBe(4);
expect(state.lastAgentOptions.autoSelectFamily).toBe(false);
});
it("uses the https agent with rejectUnauthorized=false for https:// URLs", async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("https://example.com");
expect(state.lastAgentOptions.rejectUnauthorized).toBe(false);
});
it("reuses the same keep-alive agent for repeated http requests", async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com/first");
const firstAgent = state.lastAgent;
await httpMod.httpProxy("http://example.com/second");
expect(state.lastAgentOptions.keepAlive).toBe(true);
expect(state.lastAgent).toBe(firstAgent);
});
it("returns a sanitized error response when the request fails", async () => {
state.error = Object.assign(new Error("boom"), { code: "EHOSTUNREACH" });
const httpMod = await import("./http");
const [status, contentType, data] = await httpMod.httpProxy("http://example.com/?apikey=secret");
expect(status).toBe(500);
expect(contentType).toBe("application/json");
expect(data.error.message).toBe("boom");
expect(data.error.url).toContain("apikey=***");
});
});