mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
The connect-time SSRF pin returned only the first vetted address and attached an undici@8 Agent to Node's built-in fetch. Two failures: - A host whose first DNS record is an unreachable IPv6 (no IPv6 route on the container / LAN / Tailscale) failed the connect instantly, because the single-address pin discarded the working IPv4 record behind it and Happy Eyeballs never ran. - Node's global fetch is backed by its own pinned undici copy; handing it an Agent built from the project's undici throws UND_ERR_INVALID_ARG on a dispatch-handler interface mismatch. Hand undici the full vetted survivor set and enable autoSelectFamily so it runs RFC 8305 Happy Eyeballs across the already-vetted IPs (rebinding stays closed: every dialable IP passed isPublicIp in the same lookup). Drive the pinned path through undici's own fetch so the dispatcher and the fetch engine share one undici version. Add an end-to-end regression that drives a real localhost-bound server through the pinned path (not a mocked fetch, which hid the coupling), covering the IPv6-to-IPv4 fallback, streaming, timeout, and abort.
138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { safeFetch, SafeFetchError } from "../safe-fetch";
|
|
|
|
describe("safeFetch", () => {
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
it("defaults init.redirect to 'manual'", async () => {
|
|
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
|
|
|
await safeFetch("https://example.com");
|
|
|
|
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
|
expect(init.redirect).toBe("manual");
|
|
});
|
|
|
|
it("flips to 'follow' when opts.followRedirects is true", async () => {
|
|
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
|
|
|
await safeFetch("https://example.com", {}, { followRedirects: true });
|
|
|
|
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
|
expect(init.redirect).toBe("follow");
|
|
});
|
|
|
|
it("preserves an explicit init.redirect override", async () => {
|
|
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
|
|
|
await safeFetch("https://example.com", { redirect: "error" });
|
|
|
|
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
|
expect(init.redirect).toBe("error");
|
|
});
|
|
|
|
it("aborts on timeout (override to a tight window for the test)", async () => {
|
|
// Use a real, very short timeout. `AbortSignal.timeout` is a platform
|
|
// primitive that vi.useFakeTimers cannot reliably advance, so the
|
|
// assertion runs against actual wall-clock — but 25 ms is short
|
|
// enough that the suite stays fast.
|
|
fetchSpy.mockImplementationOnce(
|
|
(_url: unknown, init?: RequestInit) =>
|
|
new Promise<Response>((_resolve, reject) => {
|
|
const signal = init?.signal;
|
|
signal?.addEventListener("abort", () => {
|
|
reject(signal.reason);
|
|
});
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
safeFetch("https://example.com", {}, { timeoutMs: 25 }),
|
|
).rejects.toMatchObject({ kind: "timeout" });
|
|
});
|
|
|
|
it("respects a caller-supplied signal via AbortSignal.any", async () => {
|
|
const controller = new AbortController();
|
|
fetchSpy.mockImplementationOnce(
|
|
(_url: unknown, init?: RequestInit) =>
|
|
new Promise<Response>((_resolve, reject) => {
|
|
const signal = init?.signal;
|
|
signal?.addEventListener("abort", () => {
|
|
reject(signal.reason);
|
|
});
|
|
}),
|
|
);
|
|
|
|
const pending = safeFetch("https://example.com", {
|
|
signal: controller.signal,
|
|
});
|
|
controller.abort();
|
|
|
|
await expect(pending).rejects.toBeInstanceOf(SafeFetchError);
|
|
await expect(pending).rejects.toMatchObject({ kind: "timeout" });
|
|
});
|
|
|
|
it("rejects a private host when requirePublicHost is true", async () => {
|
|
await expect(
|
|
safeFetch(
|
|
"http://169.254.169.254/latest/meta-data/",
|
|
{},
|
|
{ requirePublicHost: true },
|
|
),
|
|
).rejects.toMatchObject({
|
|
kind: "private_host",
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an RFC1918 host when requirePublicHost is true", async () => {
|
|
await expect(
|
|
safeFetch("http://10.0.0.5/admin", {}, { requirePublicHost: true }),
|
|
).rejects.toMatchObject({
|
|
kind: "private_host",
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT use global fetch on the pinned (requirePublicHost) path", async () => {
|
|
// The pinned path must go through undici's own `fetch` so the dispatcher
|
|
// and the fetch engine share one undici version (the version-skew fix).
|
|
// Global `fetch` must therefore be bypassed entirely. The end-to-end 200
|
|
// against a real server is asserted in `safe-fetch-pinned.test.ts`.
|
|
await expect(
|
|
safeFetch(
|
|
// 192.0.2.0/24 is TEST-NET-1 (public-classified, unroutable) so the
|
|
// connect fails fast without touching global fetch.
|
|
"https://192.0.2.1/api",
|
|
{ signal: AbortSignal.timeout(300) },
|
|
{ requirePublicHost: true },
|
|
),
|
|
).rejects.toBeInstanceOf(SafeFetchError);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
}, 20_000);
|
|
|
|
it("wraps a native network error as kind: 'network'", async () => {
|
|
fetchSpy.mockRejectedValueOnce(new TypeError("network down"));
|
|
|
|
await expect(safeFetch("https://example.com")).rejects.toMatchObject({
|
|
kind: "network",
|
|
});
|
|
});
|
|
|
|
it("accepts a URL object as the target", async () => {
|
|
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
|
|
|
await safeFetch(new URL("https://example.com/path"));
|
|
|
|
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://example.com/path");
|
|
});
|
|
});
|