99 lines
5.6 KiB
TypeScript
99 lines
5.6 KiB
TypeScript
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([]);
|
|
});
|
|
});
|