src/lib/api-proxy.test.ts
Rama main · réplica de lectura del repositorio original.
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { proxyToApi, resolveApiInternalUrl } from "./api-proxy";
const INTERNAL = "http://api.test:8000";
function fetchMock(response: Response = new Response("{}", { status: 200 })) {
const mock = vi.fn(async () => response);
vi.stubGlobal("fetch", mock);
return mock;
}
/** The `RequestInit` the handler passed to `fetch`, typed for the assertions below. */
function initOf(mock: ReturnType<typeof fetchMock>) {
return (
mock.mock.calls[0] as unknown as [string, RequestInit & { duplex?: string }]
)[1];
}
function targetOf(mock: ReturnType<typeof fetchMock>) {
return (mock.mock.calls[0] as unknown as [string, RequestInit])[0];
}
describe("resolveApiInternalUrl", () => {
it("prefers an explicit API_INTERNAL_URL", () => {
expect(resolveApiInternalUrl({ API_INTERNAL_URL: INTERNAL })).toBe(
INTERNAL,
);
});
it("strips trailing slashes, so the appended path never doubles one", () => {
expect(
resolveApiInternalUrl({ API_INTERNAL_URL: "http://api:8000//" }),
).toBe("http://api:8000");
});
it("ignores a blank value rather than dialling an empty host", () => {
expect(resolveApiInternalUrl({ API_INTERNAL_URL: " " })).toBe(
"http://localhost:8000",
);
});
it("falls back to the compose service name in production", () => {
expect(resolveApiInternalUrl({ NODE_ENV: "production" })).toBe(
"http://api:8000",
);
});
it("falls back to the API's published port under next dev", () => {
expect(resolveApiInternalUrl({ NODE_ENV: "development" })).toBe(
"http://localhost:8000",
);
});
});
describe("proxyToApi", () => {
beforeEach(() => {
vi.stubEnv("API_INTERNAL_URL", INTERNAL);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("forwards method, path and query to the internal URL", async () => {
const mock = fetchMock();
await proxyToApi(
new NextRequest("http://web.test/api/v1/dive?page=2&items_per_page=10"),
);
expect(targetOf(mock)).toBe(
`${INTERNAL}/api/v1/dive?page=2&items_per_page=10`,
);
expect(initOf(mock).method).toBe("GET");
});
it("keeps percent-encoded path segments intact", async () => {
const mock = fetchMock();
await proxyToApi(
new NextRequest("http://web.test/api/v1/dive-site/a%2Fb%20c"),
);
expect(targetOf(mock)).toBe(`${INTERNAL}/api/v1/dive-site/a%2Fb%20c`);
});
it("streams a request body through without buffering it", async () => {
const mock = fetchMock();
await proxyToApi(
new NextRequest("http://web.test/api/v1/dive", {
method: "POST",
body: '{"dive_number":1}',
headers: { "content-type": "application/json" },
}),
);
const init = initOf(mock);
expect(init.body).toBeInstanceOf(ReadableStream);
// undici refuses a stream body without it.
expect(init.duplex).toBe("half");
expect(await new Response(init.body).text()).toBe('{"dive_number":1}');
});
it("sends no body, and no duplex, for a GET", async () => {
const mock = fetchMock();
await proxyToApi(new NextRequest("http://web.test/api/v1/user"));
expect(initOf(mock).body).toBeNull();
expect(initOf(mock).duplex).toBeUndefined();
});
it("does not follow redirects or cache, and cancels with the caller", async () => {
const mock = fetchMock();
const request = new NextRequest("http://web.test/api/v1/user");
await proxyToApi(request);
const init = initOf(mock);
expect(init.redirect).toBe("manual");
expect(init.cache).toBe("no-store");
expect(init.signal).toBe(request.signal);
});
it("passes the forwarding chain through for the API's per-IP rate limits", async () => {
const mock = fetchMock();
await proxyToApi(
new NextRequest("http://web.test/api/v1/auth/email/request", {
method: "POST",
headers: {
"x-forwarded-for": "203.0.113.7, 10.0.0.2",
"x-forwarded-proto": "https",
"x-forwarded-host": "dive.example.com",
},
}),
);
const headers = initOf(mock).headers as Headers;
expect(headers.get("x-forwarded-for")).toBe("203.0.113.7, 10.0.0.2");
expect(headers.get("x-forwarded-proto")).toBe("https");
expect(headers.get("x-forwarded-host")).toBe("dive.example.com");
});
it("derives proto and host from the request when they are absent", async () => {
const mock = fetchMock();
await proxyToApi(new NextRequest("https://dive.example.com/api/v1/user"));
const headers = initOf(mock).headers as Headers;
expect(headers.get("x-forwarded-proto")).toBe("https");
expect(headers.get("x-forwarded-host")).toBe("dive.example.com");
// Nothing to invent one from - a route handler cannot see the socket peer.
expect(headers.get("x-forwarded-for")).toBeNull();
});
it("forwards the Authorization header and the request cookies", async () => {
const mock = fetchMock();
await proxyToApi(
new NextRequest("http://web.test/api/v1/auth/refresh", {
method: "POST",
headers: {
authorization: "Bearer token-abc",
cookie: "refresh_token=abc; theme=dark",
},
}),
);
const headers = initOf(mock).headers as Headers;
expect(headers.get("authorization")).toBe("Bearer token-abc");
expect(headers.get("cookie")).toBe("refresh_token=abc; theme=dark");
});
it("strips hop-by-hop headers, the ones Connection names, and the rewritten ones", async () => {
const mock = fetchMock();
await proxyToApi(
new NextRequest("http://web.test/api/v1/dive", {
method: "POST",
body: "x",
headers: {
connection: "keep-alive, X-Custom-Hop",
"keep-alive": "timeout=5",
"transfer-encoding": "chunked",
"x-custom-hop": "gone",
"accept-encoding": "gzip",
"content-length": "1",
},
}),
);
const headers = initOf(mock).headers as Headers;
for (const name of [
"connection",
"keep-alive",
"transfer-encoding",
"x-custom-hop",
"accept-encoding",
"content-length",
"host",
]) {
expect(headers.get(name)).toBeNull();
}
});
it("drops Expect, which undici refuses outright", async () => {
const mock = fetchMock();
await proxyToApi(
new NextRequest("http://web.test/api/v1/dive/x/file", {
method: "PUT",
body: "bytes",
headers: { expect: "100-continue" },
}),
);
// Not cosmetic: forwarded, this fails the whole request with
// UND_ERR_NOT_SUPPORTED, which is a 502 for every large curl upload.
expect((initOf(mock).headers as Headers).get("expect")).toBeNull();
});
it("passes every Set-Cookie through separately", async () => {
const upstream = new Response("{}", { status: 200 });
upstream.headers.append(
"set-cookie",
"refresh_token=abc; Path=/; HttpOnly; Secure; SameSite=lax; Expires=Wed, 21 Oct 2026 07:28:00 GMT",
);
upstream.headers.append("set-cookie", "other=1; Path=/");
fetchMock(upstream);
const response = await proxyToApi(
new NextRequest("http://web.test/api/v1/auth/refresh", {
method: "POST",
}),
);
const cookies = response.headers.getSetCookie();
expect(cookies).toHaveLength(2);
expect(cookies[0]).toContain("refresh_token=abc");
expect(cookies[0]).toContain("Expires=Wed, 21 Oct 2026 07:28:00 GMT");
expect(cookies[1]).toContain("other=1");
});
it("passes the status, status text and body of the response back", async () => {
fetchMock(
new Response('{"detail":"Dive not found"}', {
status: 404,
statusText: "Not Found",
headers: { "content-type": "application/json" },
}),
);
const response = await proxyToApi(
new NextRequest("http://web.test/api/v1/dive/x"),
);
expect(response.status).toBe(404);
expect(response.statusText).toBe("Not Found");
expect(response.headers.get("content-type")).toBe("application/json");
expect(await response.json()).toEqual({ detail: "Dive not found" });
});
it("keeps Content-Disposition, which is what names a downloaded file", async () => {
fetchMock(
new Response("bytes", {
headers: { "content-disposition": 'attachment; filename="dive.uddf"' },
}),
);
const response = await proxyToApi(
new NextRequest("http://web.test/api/v1/export/uddf"),
);
expect(response.headers.get("content-disposition")).toBe(
'attachment; filename="dive.uddf"',
);
});
// Starlette's slash redirect is absolute, built from the `Host` it was dialled on.
it.each([
[`${INTERNAL}/api/v1/config`, "/api/v1/config"],
[`${INTERNAL}/api/v1/dive?page=2#top`, "/api/v1/dive?page=2#top"],
["//api.test:8000/api/v1/config", "/api/v1/config"],
["/api/v1/config", "/api/v1/config"],
[
"https://accounts.google.com/o/oauth2",
"https://accounts.google.com/o/oauth2",
],
["http://api.test:9000/elsewhere", "http://api.test:9000/elsewhere"],
])("keeps the internal origin out of Location %s", async (location, sent) => {
fetchMock(new Response(null, { status: 307, headers: { location } }));
const response = await proxyToApi(
new NextRequest("http://web.test/api/v1/config%2F"),
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(sent);
});
it("drops a response's content-encoding, which fetch has already decoded away", async () => {
fetchMock(
new Response("plain", {
headers: { "content-encoding": "gzip", "content-length": "17" },
}),
);
const response = await proxyToApi(
new NextRequest("http://web.test/api/v1/dive"),
);
expect(response.headers.get("content-encoding")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
});
it("keeps content-length when the body was never encoded", async () => {
fetchMock(new Response("plain", { headers: { "content-length": "5" } }));
const response = await proxyToApi(
new NextRequest("http://web.test/api/v1/dive"),
);
expect(response.headers.get("content-length")).toBe("5");
});
it("returns a bodiless 204 rather than throwing on one", async () => {
fetchMock(new Response(null, { status: 204 }));
const response = await proxyToApi(
new NextRequest("http://web.test/api/v1/dive/x", { method: "DELETE" }),
);
expect(response.status).toBe(204);
expect(response.body).toBeNull();
});
it("answers a HEAD with headers only", async () => {
fetchMock(
new Response("ignored", { headers: { "content-type": "text/plain" } }),
);
const response = await proxyToApi(
new NextRequest("http://web.test/api/v1/dive", { method: "HEAD" }),
);
expect(response.body).toBeNull();
expect(response.headers.get("content-type")).toBe("text/plain");
});
it("answers 502 with a FastAPI-shaped body when the API is unreachable", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("getaddrinfo ENOTFOUND api");
}),
);
const response = await proxyToApi(
new NextRequest("http://web.test/api/v1/user"),
);
expect(response.status).toBe(502);
expect(await response.json()).toEqual({
detail: expect.stringContaining("unreachable"),
});
});
it("keeps the query string out of that log, where sign-in tokens live", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("connect ECONNREFUSED");
}),
);
await proxyToApi(
new NextRequest(
"http://web.test/api/v1/auth/email/verify/check?token=live-single-use-token",
),
);
// The precheck never reached the API, so that token is still valid - which is
// exactly what makes this branch the one where logging it matters.
const logged = error.mock.calls[0].join(" ");
expect(logged).not.toContain("live-single-use-token");
expect(logged).toContain("/api/v1/auth/email/verify/check");
});
});