import { describe, expect, it } from "vitest"; import { followRedirectChain, requestOnce, validateProbeUrl } from "./http"; const response = (url: string, status: number, location: string | null = null) => ({ url, status, location, headers: {} }); const publicResolution = (addresses: string[]) => ({ addresses, families: { ipv4: { addresses, status: "ok" as const, error: null }, ipv6: { addresses: [], status: "absent" as const, error: null } }, _meta: { status: "ok" as const, partial: false, errors: [] } }); describe("HTTP target policy", () => { it.each(["ftp://example.com/", "https://user:pass@example.com/", "https://example.com:8443/", "https://127.0.0.1/"])('rejects unsafe target %s', (value) => { expect(() => validateProbeUrl(new URL(value))).toThrow(); }); it("accepts standard public-host HTTP(S) targets", () => { expect(() => validateProbeUrl(new URL("https://example.com/path?q=1"))).not.toThrow(); }); }); describe("redirect chain", () => { it("follows bounded valid redirects with an injected transport", async () => { const requester = async (url: URL) => url.pathname === "/" ? response(url.toString(), 302, "/final") : response(url.toString(), 200); const chain = await followRedirectChain(new URL("https://example.com/"), requester); expect(chain).toHaveLength(2); expect(chain[1].status).toBe(200); }); it("records relative and protocol-relative redirects", async () => { const requester = async (url: URL) => url.pathname === "/" ? response(url.toString(), 301, "//example.com/final") : response(url.toString(), 204); const chain = await followRedirectChain(new URL("https://example.com/"), requester); expect(chain.at(-1)?.status).toBe(204); }); it("blocks HTTPS to HTTP downgrades before requesting the target", async () => { const requested: string[] = []; const requester = async (url: URL) => { requested.push(url.toString()); return response(url.toString(), 302, "http://example.com/"); }; const chain = await followRedirectChain(new URL("https://example.com/"), requester); expect(chain.at(-1)).toMatchObject({ code: "HTTPS_DOWNGRADE" }); expect(requested).toEqual(["https://example.com/"]); }); it("detects self-loops and multi-hop normalized loops", async () => { const self = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 301, url.toString())); expect(self.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" }); const cycle = await followRedirectChain(new URL("https://example.com/a"), async (url) => url.pathname === "/a" ? response(url.toString(), 302, "/b") : response(url.toString(), 302, "/a")); expect(cycle.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" }); const equivalent = await followRedirectChain(new URL("https://EXAMPLE.com./"), async (url) => response(url.toString(), 302, "https://example.com/")); expect(equivalent.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" }); }); it("records unsafe redirects and redirect budget exhaustion", async () => { const unsafe = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "http://127.0.0.1/admin")); expect(unsafe.at(-1)).toMatchObject({ error: expect.any(String), code: "REDIRECT_TARGET_BLOCKED" }); const limited = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "/again"), 1); expect(limited.at(-1)).toMatchObject({ code: "REDIRECT_LIMIT" }); }); it("redacts redirect userinfo from error evidence", async () => { const chain = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "https://user:secret@example.com/private")); expect(chain.at(-1)).toMatchObject({ code: "REDIRECT_TARGET_BLOCKED" }); expect(JSON.stringify(chain)).not.toContain("secret"); }); it("enforces the total deadline across slow hops", async () => { const started = Date.now(); const chain = await followRedirectChain(new URL("https://example.com/"), async (url) => { await new Promise((resolve) => setTimeout(resolve, 50)); return response(url.toString(), 200); }, 3, Date.now() + 10); expect(chain.at(-1)?.error).toBe("HTTP request timed out."); expect(Date.now() - started).toBeLessThan(100); }); }); describe("multi-address HTTP strategy", () => { it("tries the next validated public address after a transport failure", async () => { const attempts: string[] = []; const result = await requestOnce(new URL("https://example.com/"), Date.now() + 1_000, { resolve: async () => publicResolution(["1.1.1.1", "2.2.2.2"]), request: async (url, address) => { attempts.push(address); if (address === "1.1.1.1") throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); return response(url.toString(), 200); }, }); expect(result).toMatchObject({ status: 200, address: "2.2.2.2" }); expect(attempts).toEqual(["1.1.1.1", "2.2.2.2"]); expect(result.attempts).toEqual([{ address: "1.1.1.1", error: "HTTP request failed (ECONNREFUSED)." }]); }); it("rejects an unsafe address returned by a resolver before any request", async () => { const attempted: string[] = []; await expect(requestOnce(new URL("https://example.com/"), Date.now() + 1_000, { resolve: async () => publicResolution(["1.1.1.1", "127.0.0.1"]), request: async (_url, address) => { attempted.push(address); return response("https://example.com/", 200); }, })).rejects.toThrow(/non-public address space/); expect(attempted).toEqual([]); }); });