Files
HealthLog/src/lib/__tests__/safe-fetch.test.ts
T
Marc-André BombeckandClaude Opus 4.7 425503e095 feat(security): safeFetch wrapper with manual redirect and timeout defaults
Closes issue 218. Flips the convention from "remember to opt in to
redirect: manual" to "remember to opt out". Every outbound fetch that
doesn't pin a hard-coded host now routes through safeFetch and inherits
AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a
worker indefinitely. The local AI client also gates on
requirePublicHost: true since its host is user-supplied; operators
running self-hosted Ollama / LM Studio on an RFC1918 address keep their
escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at
write-time in /api/user/ai-provider).

14 outbound call sites migrated. The four that already pinned manual
redirect (the historic templates) keep their semantics; the wrapper
preserves an explicit init.redirect. Adds undici as an explicit
dependency so the connect-time pin landing alongside this can rely on
the documented API rather than a transitive hop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00

134 lines
4.1 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("passes a public host through with requirePublicHost", async () => {
fetchSpy.mockResolvedValueOnce(new Response("ok", { status: 200 }));
const res = await safeFetch(
"https://example.com/api",
{},
{ requirePublicHost: true },
);
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledOnce();
});
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");
});
});