diff --git a/src/__tests__/pages/api/widgets/customapi.test.js b/src/__tests__/pages/api/widgets/customapi.test.js new file mode 100644 index 000000000..f2ef68617 --- /dev/null +++ b/src/__tests__/pages/api/widgets/customapi.test.js @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getPrivateWidgetOptions, httpProxy, logger } = vi.hoisted(() => ({ + getPrivateWidgetOptions: vi.fn(), + httpProxy: vi.fn(), + logger: { debug: vi.fn() }, +})); + +vi.mock("utils/config/widget-helpers", () => ({ + getPrivateWidgetOptions, +})); + +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +import handler from "pages/api/widgets/customapi"; + +describe("pages/api/widgets/customapi", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when the widget URL is missing", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({}); + const res = createMockRes(); + + await handler({ query: { index: "0" } }, res); + + expect(getPrivateWidgetOptions).toHaveBeenCalledWith("customapi", "0"); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe("Missing Custom API URL"); + }); + + it("returns 400 when the widget URL is invalid", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({ url: "not a url" }); + const res = createMockRes(); + + await handler({ query: { index: "0" } }, res); + + expect(res.statusCode).toBe(400); + expect(httpProxy).not.toHaveBeenCalled(); + }); + + it("proxies with headers, basic auth, method and JSON body", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({ + url: "http://api.local/data", + username: "u", + password: "p", + method: "POST", + headers: { "X-Test": "1" }, + requestBody: { foo: "bar" }, + }); + httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from('{"a":1}')]); + const res = createMockRes(); + + await handler({ query: { index: "0" } }, res); + + expect(httpProxy).toHaveBeenCalledWith(new URL("http://api.local/data"), { + method: "POST", + headers: { "X-Test": "1", Authorization: `Basic ${Buffer.from("u:p").toString("base64")}` }, + body: '{"foo":"bar"}', + }); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/json"); + expect(res.statusCode).toBe(200); + expect(res.body.toString()).toBe('{"a":1}'); + }); + + it("defaults to GET and passes string bodies through", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://api.local", requestBody: "raw" }); + httpProxy.mockResolvedValueOnce([200, null, Buffer.from("{}")]); + const res = createMockRes(); + + await handler({ query: { index: "0" } }, res); + + expect(httpProxy).toHaveBeenCalledWith(expect.any(URL), { method: "GET", headers: {}, body: "raw" }); + }); + + it("returns a sanitized error without upstream data on HTTP errors", async () => { + getPrivateWidgetOptions.mockResolvedValueOnce({ url: "http://api.local/secret?token=abc" }); + httpProxy.mockResolvedValueOnce([500, "text/plain", Buffer.from("boom")]); + const res = createMockRes(); + + await handler({ query: { index: "0" } }, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: { message: "HTTP Error", url: "api.local (see logs for details)" } }); + }); +}); diff --git a/src/pages/api/widgets/customapi.js b/src/pages/api/widgets/customapi.js new file mode 100644 index 000000000..2d6d4370d --- /dev/null +++ b/src/pages/api/widgets/customapi.js @@ -0,0 +1,44 @@ +import { getPrivateWidgetOptions } from "utils/config/widget-helpers"; +import createLogger from "utils/logger"; +import { sanitizeErrorURL } from "utils/proxy/api-helpers"; +import { httpProxy } from "utils/proxy/http"; + +const logger = createLogger("customapi"); + +export default async function handler(req, res) { + const { index } = req.query; + + const options = await getPrivateWidgetOptions("customapi", index); + + if (!options?.url) { + return res.status(400).json({ error: "Missing Custom API URL" }); + } + + let url; + try { + url = new URL(options.url); + } catch { + return res.status(400).json({ error: "Invalid Custom API URL" }); + } + + const headers = { ...(options.headers ?? {}) }; + if (options.username && options.password) { + headers.Authorization = `Basic ${Buffer.from(`${options.username}:${options.password}`).toString("base64")}`; + } + + const params = { method: options.method ?? "GET", headers }; + if (options.requestBody) { + params.body = typeof options.requestBody === "object" ? JSON.stringify(options.requestBody) : options.requestBody; + } + + const [status, contentType, data] = await httpProxy(url, params); + + if (status >= 400) { + logger.debug("HTTP Error %d calling %s//%s%s", status, url.protocol, url.host, url.pathname); + return res.status(status).json({ error: { message: "HTTP Error", url: sanitizeErrorURL(url) } }); + } + + if (contentType) res.setHeader("Content-Type", contentType); + + return res.status(status).send(data); +}