mirror of
https://github.com/gethomepage/homepage.git
synced 2026-04-05 09:41:21 -07:00
Chore: homepage tests (#6278)
This commit is contained in:
92
src/utils/proxy/api-helpers.test.js
Normal file
92
src/utils/proxy/api-helpers.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
asJson,
|
||||
formatApiCall,
|
||||
formatProxyUrl,
|
||||
getURLSearchParams,
|
||||
jsonArrayFilter,
|
||||
jsonArrayTransform,
|
||||
sanitizeErrorURL,
|
||||
} from "./api-helpers";
|
||||
|
||||
describe("utils/proxy/api-helpers", () => {
|
||||
it("formatApiCall replaces placeholders and trims trailing slashes for {url}", () => {
|
||||
expect(formatApiCall("{url}/{endpoint}", { url: "http://localhost///", endpoint: "api" })).toBe(
|
||||
"http://localhost/api",
|
||||
);
|
||||
});
|
||||
|
||||
it("formatApiCall replaces repeated placeholders", () => {
|
||||
expect(formatApiCall("{a}-{a}-{missing}", { a: "x" })).toBe("x-x-");
|
||||
});
|
||||
|
||||
it("getURLSearchParams includes group/service/index and optionally endpoint", () => {
|
||||
const widget = { service_group: "g", service_name: "s", index: "0" };
|
||||
|
||||
const withEndpoint = getURLSearchParams(widget, "stats");
|
||||
expect(withEndpoint.get("group")).toBe("g");
|
||||
expect(withEndpoint.get("service")).toBe("s");
|
||||
expect(withEndpoint.get("index")).toBe("0");
|
||||
expect(withEndpoint.get("endpoint")).toBe("stats");
|
||||
|
||||
const withoutEndpoint = getURLSearchParams(widget);
|
||||
expect(withoutEndpoint.get("endpoint")).toBeNull();
|
||||
});
|
||||
|
||||
it("formatProxyUrl builds expected proxy URL and encodes query params", () => {
|
||||
const widget = { service_group: "g", service_name: "s", index: "2" };
|
||||
const url = formatProxyUrl(widget, "health", { a: 1, b: "x" });
|
||||
|
||||
expect(url.startsWith("/api/services/proxy?")).toBe(true);
|
||||
|
||||
const qs = url.split("?")[1];
|
||||
const params = new URLSearchParams(qs);
|
||||
expect(params.get("group")).toBe("g");
|
||||
expect(params.get("service")).toBe("s");
|
||||
expect(params.get("index")).toBe("2");
|
||||
expect(params.get("endpoint")).toBe("health");
|
||||
|
||||
expect(JSON.parse(params.get("query"))).toEqual({ a: 1, b: "x" });
|
||||
});
|
||||
|
||||
it("asJson parses JSON buffers and returns non-JSON values unchanged", () => {
|
||||
expect(asJson(Buffer.from(JSON.stringify({ ok: true })))).toEqual({ ok: true });
|
||||
expect(asJson(Buffer.from(""))).toEqual(Buffer.from(""));
|
||||
expect(asJson(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("jsonArrayTransform transforms arrays and returns non-arrays unchanged", () => {
|
||||
const data = Buffer.from(JSON.stringify([{ a: 1 }, { a: 2 }]));
|
||||
expect(jsonArrayTransform(data, (items) => items.map((i) => i.a))).toEqual([1, 2]);
|
||||
|
||||
expect(jsonArrayTransform(Buffer.from(JSON.stringify({ ok: true })), () => "nope")).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("jsonArrayFilter filters arrays and returns non-arrays unchanged", () => {
|
||||
const data = Buffer.from(JSON.stringify([{ a: 1 }, { a: 2 }]));
|
||||
expect(jsonArrayFilter(data, (item) => item.a > 1)).toEqual([{ a: 2 }]);
|
||||
});
|
||||
|
||||
it("sanitizeErrorURL redacts sensitive query params and hash fragments", () => {
|
||||
const input = "https://example.com/path?apikey=123&token=abc#access_token=xyz&other=1";
|
||||
const output = sanitizeErrorURL(input);
|
||||
|
||||
const url = new URL(output);
|
||||
expect(url.searchParams.get("apikey")).toBe("***");
|
||||
expect(url.searchParams.get("token")).toBe("***");
|
||||
expect(url.hash).toContain("access_token=***");
|
||||
expect(url.hash).toContain("other=1");
|
||||
});
|
||||
|
||||
it("sanitizeErrorURL only redacts known keys", () => {
|
||||
const input = "https://example.com/path?api_key=123&safe=ok#auth=abc&safe_hash=1";
|
||||
const output = sanitizeErrorURL(input);
|
||||
|
||||
const url = new URL(output);
|
||||
expect(url.searchParams.get("api_key")).toBe("***");
|
||||
expect(url.searchParams.get("safe")).toBe("ok");
|
||||
expect(url.hash).toContain("auth=***");
|
||||
expect(url.hash).toContain("safe_hash=1");
|
||||
});
|
||||
});
|
||||
45
src/utils/proxy/cookie-jar.test.js
Normal file
45
src/utils/proxy/cookie-jar.test.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("utils/proxy/cookie-jar", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("adds cookies to the jar and sets Cookie header on subsequent requests", async () => {
|
||||
const { addCookieToJar, setCookieHeader } = await import("./cookie-jar");
|
||||
|
||||
const url = new URL("http://example.test/path");
|
||||
addCookieToJar(url, { "set-cookie": ["a=b; Path=/"] });
|
||||
|
||||
const params = { headers: {} };
|
||||
setCookieHeader(url, params);
|
||||
|
||||
expect(params.headers.Cookie).toContain("a=b");
|
||||
});
|
||||
|
||||
it("supports custom cookie header names via params.cookieHeader", async () => {
|
||||
const { addCookieToJar, setCookieHeader } = await import("./cookie-jar");
|
||||
|
||||
const url = new URL("http://example2.test/path");
|
||||
addCookieToJar(url, { "set-cookie": ["sid=1; Path=/"] });
|
||||
|
||||
const params = { headers: {}, cookieHeader: "X-Auth-Token" };
|
||||
setCookieHeader(url, params);
|
||||
|
||||
expect(params.headers["X-Auth-Token"]).toContain("sid=1");
|
||||
});
|
||||
|
||||
it("supports Headers instances passed as response headers", async () => {
|
||||
const { addCookieToJar, setCookieHeader } = await import("./cookie-jar");
|
||||
|
||||
const url = new URL("http://example3.test/path");
|
||||
const headers = new Headers();
|
||||
headers.set("set-cookie", "c=d; Path=/");
|
||||
addCookieToJar(url, headers);
|
||||
|
||||
const params = { headers: {} };
|
||||
setCookieHeader(url, params);
|
||||
|
||||
expect(params.headers.Cookie).toContain("c=d");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,11 @@ export default async function credentialedProxyHandler(req, res, map) {
|
||||
if (group && service) {
|
||||
const widget = await getServiceWidget(group, service, index);
|
||||
|
||||
if (!widget) {
|
||||
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
|
||||
return res.status(400).json({ error: "Invalid proxy service type" });
|
||||
}
|
||||
|
||||
if (!widgets?.[widget.type]?.api) {
|
||||
return res.status(403).json({ error: "Service does not support API calls" });
|
||||
}
|
||||
|
||||
404
src/utils/proxy/handlers/credentialed.test.js
Normal file
404
src/utils/proxy/handlers/credentialed.test.js
Normal file
@@ -0,0 +1,404 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { httpProxy } = vi.hoisted(() => ({ httpProxy: vi.fn() }));
|
||||
const { validateWidgetData } = vi.hoisted(() => ({ validateWidgetData: vi.fn(() => true) }));
|
||||
const { getServiceWidget } = vi.hoisted(() => ({ getServiceWidget: vi.fn() }));
|
||||
const { getSettings } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(() => ({ providers: { finnhub: "finnhub-token" } })),
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({ httpProxy }));
|
||||
vi.mock("utils/proxy/validate-widget-data", () => ({ default: validateWidgetData }));
|
||||
vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget }));
|
||||
vi.mock("utils/config/config", () => ({ getSettings }));
|
||||
|
||||
// Keep the widget registry minimal so the test doesn't import the whole widget graph.
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
coinmarketcap: { api: "{url}/{endpoint}" },
|
||||
gotify: { api: "{url}/{endpoint}" },
|
||||
plantit: { api: "{url}/{endpoint}" },
|
||||
myspeed: { api: "{url}/{endpoint}" },
|
||||
esphome: { api: "{url}/{endpoint}" },
|
||||
wgeasy: { api: "{url}/{endpoint}" },
|
||||
linkwarden: { api: "{url}/api/v1/{endpoint}" },
|
||||
miniflux: { api: "{url}/{endpoint}" },
|
||||
nextcloud: { api: "{url}/ocs/v2.php/apps/serverinfo/api/v1/{endpoint}" },
|
||||
paperlessngx: { api: "{url}/api/{endpoint}" },
|
||||
proxmox: { api: "{url}/api2/json/{endpoint}" },
|
||||
truenas: { api: "{url}/api/v2.0/{endpoint}" },
|
||||
proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" },
|
||||
checkmk: { api: "{url}/{endpoint}" },
|
||||
stocks: { api: "{url}/{endpoint}" },
|
||||
speedtest: { api: "{url}/{endpoint}" },
|
||||
tubearchivist: { api: "{url}/{endpoint}" },
|
||||
autobrr: { api: "{url}/{endpoint}" },
|
||||
jellystat: { api: "{url}/{endpoint}" },
|
||||
trilium: { api: "{url}/{endpoint}" },
|
||||
gitlab: { api: "{url}/{endpoint}" },
|
||||
azuredevops: { api: "{url}/{endpoint}" },
|
||||
glances: { api: "{url}/{endpoint}" },
|
||||
withheaders: { api: "{url}/{endpoint}", headers: { "X-Widget": "1" } },
|
||||
},
|
||||
}));
|
||||
|
||||
import credentialedProxyHandler from "./credentialed";
|
||||
|
||||
function createMockRes() {
|
||||
const res = {
|
||||
headers: {},
|
||||
statusCode: undefined,
|
||||
body: undefined,
|
||||
setHeader: (k, v) => {
|
||||
res.headers[k] = v;
|
||||
},
|
||||
status: (code) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json: (data) => {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
send: (data) => {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
end: () => res,
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("utils/proxy/handlers/credentialed", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
validateWidgetData.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("returns 400 when group/service are missing", async () => {
|
||||
const req = { method: "GET", query: { endpoint: "e", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
|
||||
it("returns 400 when the widget cannot be resolved", async () => {
|
||||
getServiceWidget.mockResolvedValue(false);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
|
||||
it("returns 403 when the widget type does not support API calls", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "noapi", url: "http://example", key: "token" });
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Service does not support API calls" });
|
||||
});
|
||||
|
||||
it("uses Bearer auth for linkwarden widgets", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalled();
|
||||
const [, params] = httpProxy.mock.calls[0];
|
||||
expect(params.headers.Authorization).toBe("Bearer token");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("uses NC-Token auth for nextcloud widgets when key is provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "nextcloud", url: "http://example", key: "nc-token" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "status", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers["NC-Token"]).toBe("nc-token");
|
||||
expect(params.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses basic auth for nextcloud when key is not provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "nextcloud", url: "http://example", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "status", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("uses basic auth for truenas when key is not provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "truenas", url: "http://nas", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "system/info", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("uses Bearer auth for truenas when key is provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "truenas", url: "http://nas", key: "k" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "system/info", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toBe("Bearer k");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ type: "paperlessngx", url: "http://x", key: "k" }, { Authorization: "Token k" }],
|
||||
[
|
||||
{ type: "paperlessngx", url: "http://x", username: "u", password: "p" },
|
||||
{ Authorization: expect.stringMatching(/^Basic /) },
|
||||
],
|
||||
])("sets paperlessngx auth mode for %o", async (widget, expected) => {
|
||||
getServiceWidget.mockResolvedValue(widget);
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "documents", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers).toEqual(expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("uses basic auth for esphome when username/password are provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "esphome", url: "http://x", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "e", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("uses basic auth for wgeasy when username/password are provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "wgeasy", url: "http://x", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "e", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("covers additional auth/header modes for common widgets", async () => {
|
||||
const cases = [
|
||||
[{ type: "coinmarketcap", url: "http://x", key: "k" }, { "X-CMC_PRO_API_KEY": "k" }],
|
||||
[{ type: "gotify", url: "http://x", key: "k" }, { "X-gotify-Key": "k" }],
|
||||
[{ type: "plantit", url: "http://x", key: "k" }, { Key: "k" }],
|
||||
[{ type: "myspeed", url: "http://x", password: "p" }, { Password: "p" }],
|
||||
[{ type: "proxmox", url: "http://x", username: "u", password: "p" }, { Authorization: "PVEAPIToken=u=p" }],
|
||||
[{ type: "autobrr", url: "http://x", key: "k" }, { "X-API-Token": "k" }],
|
||||
[{ type: "jellystat", url: "http://x", key: "k" }, { "X-API-Token": "k" }],
|
||||
[{ type: "tubearchivist", url: "http://x", key: "k" }, { Authorization: "Token k" }],
|
||||
[{ type: "miniflux", url: "http://x", key: "k" }, { "X-Auth-Token": "k" }],
|
||||
[{ type: "trilium", url: "http://x", key: "k" }, { Authorization: "k" }],
|
||||
[{ type: "gitlab", url: "http://x", key: "k" }, { "PRIVATE-TOKEN": "k" }],
|
||||
[{ type: "speedtest", url: "http://x", key: "k" }, { Authorization: "Bearer k" }],
|
||||
[
|
||||
{ type: "azuredevops", url: "http://x", key: "k" },
|
||||
{ Authorization: `Basic ${Buffer.from("$:k").toString("base64")}` },
|
||||
],
|
||||
[
|
||||
{ type: "glances", url: "http://x", username: "u", password: "p" },
|
||||
{ Authorization: expect.stringMatching(/^Basic /) },
|
||||
],
|
||||
[{ type: "wgeasy", url: "http://x", password: "p" }, { Authorization: "p" }],
|
||||
[{ type: "esphome", url: "http://x", key: "cookie" }, { Cookie: "authenticated=cookie" }],
|
||||
];
|
||||
|
||||
for (const [widget, expected] of cases) {
|
||||
getServiceWidget.mockResolvedValue(widget);
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "e", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers).toEqual(expect.objectContaining(expected));
|
||||
}
|
||||
});
|
||||
|
||||
it("merges registry/widget/request headers and falls back to X-API-Key for unknown types", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "withheaders",
|
||||
url: "http://example",
|
||||
key: "k",
|
||||
headers: { "X-From-Widget": "2" },
|
||||
});
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = {
|
||||
method: "GET",
|
||||
query: { group: "g", service: "s", endpoint: "collections", index: 0 },
|
||||
extraHeaders: { "X-From-Req": "3" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers).toEqual(
|
||||
expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Widget": "1",
|
||||
"X-From-Widget": "2",
|
||||
"X-From-Req": "3",
|
||||
"X-API-Key": "k",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets PBSAPIToken auth and removes content-type for proxmoxbackupserver", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "proxmoxbackupserver",
|
||||
url: "http://pbs",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "nodes", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers["Content-Type"]).toBeUndefined();
|
||||
expect(params.headers.Authorization).toBe("PBSAPIToken=u:p");
|
||||
});
|
||||
|
||||
it("uses checkmk's Bearer username password auth format", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "checkmk", url: "http://checkmk", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = {
|
||||
method: "GET",
|
||||
query: { group: "g", service: "s", endpoint: "domain-types/host_config/collections/all", index: 0 },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Accept).toBe("application/json");
|
||||
expect(params.headers.Authorization).toBe("Bearer u p");
|
||||
});
|
||||
|
||||
it("injects the configured finnhub provider token for stocks widgets", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "stocks", url: "http://stocks", provider: "finnhub" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "quote", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers["X-Finnhub-Token"]).toBe("finnhub-token");
|
||||
});
|
||||
|
||||
it("sanitizes embedded query params when a downstream error contains a url", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([500, "application/json", { error: { message: "oops", url: "http://bad" } }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections?apikey=secret", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error.url).toContain("apikey=***");
|
||||
});
|
||||
|
||||
it("ends the response for 204/304 statuses", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([204, "application/json", Buffer.from("")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it("returns invalid data errors as 500 when validation fails on 200 responses", async () => {
|
||||
validateWidgetData.mockReturnValueOnce(false);
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error.message).toBe("Invalid data");
|
||||
expect(res.body.error.url).toContain("http://example/api/v1/collections");
|
||||
});
|
||||
|
||||
it("applies the response mapping function when provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true, value: 1 }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res, (data) => ({ ok: data.ok, v: data.value }));
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true, v: 1 });
|
||||
});
|
||||
});
|
||||
256
src/utils/proxy/handlers/generic.test.js
Normal file
256
src/utils/proxy/handlers/generic.test.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, validateWidgetData, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
validateWidgetData: vi.fn(() => true),
|
||||
logger: { debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
vi.mock("utils/proxy/validate-widget-data", () => ({
|
||||
default: validateWidgetData,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
testservice: {
|
||||
api: "{url}/{endpoint}",
|
||||
},
|
||||
customapi: {
|
||||
api: "{url}/{endpoint}",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import genericProxyHandler from "./generic";
|
||||
|
||||
describe("utils/proxy/handlers/generic", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
validateWidgetData.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("returns 403 when the service widget type does not define an API mapping", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "missing",
|
||||
url: "http://example",
|
||||
});
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Service does not support API calls" });
|
||||
});
|
||||
|
||||
it("replaces extra '?' characters in the endpoint with '&'", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "x?a=1?b=2", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(1);
|
||||
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://example/x?a=1&b=2");
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("preserves trailing slash for customapi widgets when widget.url ends with /", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "customapi",
|
||||
url: "http://example/",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "path", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://example/path/");
|
||||
});
|
||||
|
||||
it("uses widget.requestBody as a string when req.body is not provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
method: "POST",
|
||||
requestBody: "raw-body",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = { method: "POST", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy.mock.calls[0][1].body).toBe("raw-body");
|
||||
});
|
||||
|
||||
it("uses requestBody and basic auth headers when provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
method: "POST",
|
||||
username: "u",
|
||||
password: "p",
|
||||
requestBody: { hello: "world" },
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy.mock.calls[0][1].method).toBe("POST");
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toMatch(/^Basic /);
|
||||
expect(httpProxy.mock.calls[0][1].body).toBe(JSON.stringify({ hello: "world" }));
|
||||
});
|
||||
|
||||
it("sanitizes error urls embedded in successful payloads", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
{
|
||||
error: {
|
||||
url: "http://upstream.example/?apikey=secret",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api?apikey=secret", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.error.url).toContain("apikey=***");
|
||||
});
|
||||
|
||||
it("returns an Invalid data error when validation fails", async () => {
|
||||
validateWidgetData.mockReturnValue(false);
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", { bad: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.error.message).toBe("Invalid data");
|
||||
});
|
||||
|
||||
it("uses string requestBody as-is and prefers req.body over widget.requestBody", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
requestBody: '{"a":1}',
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = {
|
||||
method: "POST",
|
||||
body: "override-body",
|
||||
query: { group: "g", service: "svc", endpoint: "api", index: "0" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(1);
|
||||
expect(httpProxy.mock.calls[0][1].body).toBe("override-body");
|
||||
});
|
||||
|
||||
it("ends the response for 204/304 statuses", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([204, "application/json", Buffer.from("")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an HTTP Error object for status>=400 and stringifies buffer data", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([500, "application/json", Buffer.from("fail")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api?apikey=secret", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error.message).toBe("HTTP Error");
|
||||
expect(res.body.error.url).toContain("apikey=***");
|
||||
expect(res.body.error.data).toBe("fail");
|
||||
});
|
||||
|
||||
it("returns 400 when group/service are missing", async () => {
|
||||
const req = { method: "GET", query: { endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
expect(logger.debug).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the response mapping function when provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res, (data) => ({ mapped: data.ok }));
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ mapped: true });
|
||||
});
|
||||
});
|
||||
219
src/utils/proxy/handlers/jsonrpc.test.js
Normal file
219
src/utils/proxy/handlers/jsonrpc.test.js
Normal file
@@ -0,0 +1,219 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: { debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
rpcwidget: {
|
||||
api: "{url}/jsonrpc",
|
||||
mappings: {
|
||||
list: { endpoint: "test.method", params: [1, 2] },
|
||||
},
|
||||
},
|
||||
missingapi: {
|
||||
mappings: {
|
||||
list: { endpoint: "test.method", params: [1, 2] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("utils/proxy/handlers/jsonrpc sendJsonRpcRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends a JSON-RPC request and returns the response", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
return [
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: req.id, result: { ok: true } })),
|
||||
];
|
||||
});
|
||||
|
||||
const [status, contentType, data] = await sendJsonRpcRequest("http://rpc", "test.method", [1], {
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(contentType).toBe("application/json");
|
||||
expect(JSON.parse(data)).toEqual({ ok: true });
|
||||
expect(httpProxy).toHaveBeenCalledTimes(1);
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("maps JSON-RPC error responses into a result=null error object", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
return [
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: req.id, result: null, error: { code: 123, message: "bad" } })),
|
||||
];
|
||||
});
|
||||
|
||||
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(JSON.parse(data)).toEqual({ result: null, error: { code: 123, message: "bad" } });
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer token");
|
||||
});
|
||||
|
||||
it("prefers Bearer auth when both basic credentials and a key are provided", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
return [
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: req.id, result: { ok: true } })),
|
||||
];
|
||||
});
|
||||
|
||||
const [, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, {
|
||||
username: "u",
|
||||
password: "p",
|
||||
key: "token",
|
||||
});
|
||||
|
||||
expect(JSON.parse(data)).toEqual({ ok: true });
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer token");
|
||||
});
|
||||
|
||||
it("maps transport/parse failures into a JSON-RPC style error response", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("not-json")]);
|
||||
|
||||
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(JSON.parse(data)).toEqual({
|
||||
result: null,
|
||||
error: { code: expect.any(Number), message: expect.any(String) },
|
||||
});
|
||||
expect(logger.debug).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes id=null responses so the client can still receive a result", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
expect(req.id).toBe(1);
|
||||
return [200, "application/json", Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: null, result: { ok: true } }))];
|
||||
});
|
||||
|
||||
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(JSON.parse(data)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("utils/proxy/handlers/jsonrpc proxy handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("looks up the widget, applies mappings, and returns JSON-RPC data", async () => {
|
||||
const { default: jsonrpcProxyHandler } = await import("./jsonrpc");
|
||||
|
||||
getServiceWidget.mockResolvedValue({ type: "rpcwidget", url: "http://rpc", key: "token" });
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
return [
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: req.id, result: { method: req.method, params: req.params } })),
|
||||
];
|
||||
});
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "test.method", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await jsonrpcProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json).toEqual({ method: "test.method", params: [1, 2] });
|
||||
});
|
||||
|
||||
it("returns 403 when the widget does not support API calls", async () => {
|
||||
const { default: jsonrpcProxyHandler } = await import("./jsonrpc");
|
||||
|
||||
getServiceWidget.mockResolvedValue({ type: "missingapi", url: "http://rpc" });
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "test.method", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await jsonrpcProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Service does not support API calls" });
|
||||
});
|
||||
|
||||
it("returns 400 for invalid requests without group/service", async () => {
|
||||
const { default: jsonrpcProxyHandler } = await import("./jsonrpc");
|
||||
|
||||
const req = { method: "GET", query: { endpoint: "test.method" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await jsonrpcProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("utils/proxy/handlers/jsonrpc unexpected errors", () => {
|
||||
it("returns 500 when the JSON-RPC client throws a non-JSONRPCErrorException", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("json-rpc-2.0", () => {
|
||||
class JSONRPCErrorException extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
class JSONRPCClient {
|
||||
constructor() {}
|
||||
|
||||
receive() {}
|
||||
|
||||
async request() {
|
||||
throw new Error("boom");
|
||||
}
|
||||
}
|
||||
|
||||
return { JSONRPCClient, JSONRPCErrorException };
|
||||
});
|
||||
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
|
||||
|
||||
expect(status).toBe(500);
|
||||
expect(JSON.parse(data)).toEqual({ result: null, error: { code: 2, message: "Error: boom" } });
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,9 @@ export default async function synologyProxyHandler(req, res) {
|
||||
}
|
||||
|
||||
const serviceWidget = await getServiceWidget(group, service, index);
|
||||
if (!serviceWidget) {
|
||||
return res.status(400).json({ error: "Invalid proxy service type" });
|
||||
}
|
||||
const widget = widgets?.[serviceWidget.type];
|
||||
const mapping = widget?.mappings?.[endpoint];
|
||||
if (!widget.api || !mapping) {
|
||||
@@ -158,7 +161,8 @@ export default async function synologyProxyHandler(req, res) {
|
||||
let [status, contentType, data] = await httpProxy(url);
|
||||
if (status !== 200) {
|
||||
logger.debug("Error %d calling url %s", status, url);
|
||||
return res.status(status, data);
|
||||
if (contentType) res.setHeader("Content-Type", contentType);
|
||||
return res.status(status).send(data);
|
||||
}
|
||||
|
||||
let json = asJson(data);
|
||||
|
||||
380
src/utils/proxy/handlers/synology.test.js
Normal file
380
src/utils/proxy/handlers/synology.test.js
Normal file
@@ -0,0 +1,380 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||
const store = new Map();
|
||||
return {
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
cache: {
|
||||
get: vi.fn((k) => store.get(k)),
|
||||
put: vi.fn((k, v) => store.set(k, v)),
|
||||
del: vi.fn((k) => store.delete(k)),
|
||||
_reset: () => store.clear(),
|
||||
},
|
||||
logger: { debug: vi.fn(), warn: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
...cache,
|
||||
}));
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
synology: {
|
||||
api: "{url}/webapi/{cgiPath}?api={apiName}&version={maxVersion}&method={apiMethod}",
|
||||
mappings: {
|
||||
download: { apiName: "SYNO.DownloadStation2.Task", apiMethod: "list" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import synologyProxyHandler from "./synology";
|
||||
|
||||
describe("utils/proxy/handlers/synology", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cache._reset();
|
||||
});
|
||||
|
||||
it("returns 400 when group/service are missing", async () => {
|
||||
const req = { query: { endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
|
||||
it("returns 400 when the widget cannot be resolved", async () => {
|
||||
getServiceWidget.mockResolvedValue(false);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
|
||||
it("returns 403 when the endpoint is not mapped", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "nope", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Service does not support API calls" });
|
||||
});
|
||||
|
||||
it("calls the mapped API when api info is available and success is true", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 } } })),
|
||||
])
|
||||
// api call
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: true, data: { ok: true } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(2);
|
||||
expect(httpProxy.mock.calls[1][0]).toContain("/webapi/entry.cgi?api=SYNO.DownloadStation2.Task");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body.toString()).data.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("caches api info lookups to avoid repeated query calls", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// first call info query
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 } } })),
|
||||
])
|
||||
// first call api
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))])
|
||||
// second call api only (info should be cached)
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res1 = createMockRes();
|
||||
const res2 = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res1);
|
||||
await synologyProxyHandler(req, res2);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
// second invocation should not re-fetch api info
|
||||
expect(httpProxy.mock.calls[2][0]).toContain("/webapi/entry.cgi?api=SYNO.DownloadStation2.Task");
|
||||
});
|
||||
|
||||
it("returns non-200 proxy responses as-is (with content-type)", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 } } })),
|
||||
])
|
||||
.mockResolvedValueOnce([503, "text/plain", Buffer.from("nope")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.headers["Content-Type"]).toBe("text/plain");
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(res.body).toEqual(Buffer.from("nope"));
|
||||
});
|
||||
|
||||
it("returns 400 when the API name is unrecognized", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ data: {} }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Unrecognized API name: SYNO.DownloadStation2.Task" });
|
||||
});
|
||||
|
||||
it("logs a warning when API info returns invalid JSON and treats the API name as unrecognized", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("{not json")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Unrecognized API name: SYNO.DownloadStation2.Task" });
|
||||
});
|
||||
|
||||
it("includes a 2FA hint when authentication fails with a 403+ error code", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query for mapping api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
"SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 },
|
||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
// api call returns success false -> triggers login
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),
|
||||
])
|
||||
// info query for auth api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 } } })),
|
||||
])
|
||||
// login returns success false with 2fa-required code
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 403 } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual(expect.objectContaining({ code: 403, error: expect.stringContaining("2FA") }));
|
||||
});
|
||||
|
||||
it("handles non-200 login responses and surfaces a synology error code", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query for mapping api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
"SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 },
|
||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
// api call returns success false -> triggers login
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),
|
||||
])
|
||||
// info query for auth api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 } } })),
|
||||
])
|
||||
// login is non-200 => login() returns early
|
||||
.mockResolvedValueOnce([
|
||||
503,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 103 } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ code: 103, error: "The requested method does not exist." });
|
||||
});
|
||||
|
||||
it("attempts login and retries when the initial response is unsuccessful", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query for mapping api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
"SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 },
|
||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
// api call returns success false
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),
|
||||
])
|
||||
// info query for auth api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 } } })),
|
||||
])
|
||||
// login success
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))])
|
||||
// retry still fails
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ code: 106, error: "Session timeout." });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[102, "The requested API does not exist."],
|
||||
[104, "The requested version does not support the functionality."],
|
||||
[105, "The logged in session does not have permission."],
|
||||
[107, "Session interrupted by duplicated login."],
|
||||
[119, "Invalid session or SID not found."],
|
||||
])("maps synology error code %s to a friendly error", async (code, expected) => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query for mapping api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
"SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 },
|
||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
// api call returns success false -> triggers login
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code } })),
|
||||
])
|
||||
// info query for auth api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 } } })),
|
||||
])
|
||||
// login success
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))])
|
||||
// retry still fails with the same code
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ code, error: expected });
|
||||
});
|
||||
});
|
||||
@@ -249,6 +249,7 @@ export async function httpProxy(url, params = {}) {
|
||||
const [status, contentType, data, responseHeaders] = await request;
|
||||
return [status, contentType, data, responseHeaders, params];
|
||||
} catch (err) {
|
||||
const rawError = Array.isArray(err) ? err[1] : err;
|
||||
logger.error(
|
||||
"Error calling %s//%s%s%s...",
|
||||
constructedUrl.protocol,
|
||||
@@ -260,7 +261,13 @@ export async function httpProxy(url, params = {}) {
|
||||
return [
|
||||
500,
|
||||
"application/json",
|
||||
{ error: { message: err?.message ?? "Unknown error", url: sanitizeErrorURL(url), rawError: err } },
|
||||
{
|
||||
error: {
|
||||
message: rawError?.message ?? "Unknown error",
|
||||
url: sanitizeErrorURL(url),
|
||||
rawError,
|
||||
},
|
||||
},
|
||||
null,
|
||||
];
|
||||
}
|
||||
|
||||
423
src/utils/proxy/http.test.js
Normal file
423
src/utils/proxy/http.test.js
Normal file
@@ -0,0 +1,423 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { state, cache, logger, dns, net, cookieJar } = vi.hoisted(() => ({
|
||||
state: {
|
||||
response: {
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Buffer.from(""),
|
||||
},
|
||||
error: null,
|
||||
lastAgentOptions: null,
|
||||
lastRequestParams: null,
|
||||
lastWrittenBody: null,
|
||||
},
|
||||
cache: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
dns: {
|
||||
lookup: vi.fn(),
|
||||
resolve4: vi.fn(),
|
||||
resolve6: vi.fn(),
|
||||
},
|
||||
net: {
|
||||
isIP: vi.fn(),
|
||||
},
|
||||
cookieJar: {
|
||||
addCookieToJar: vi.fn(),
|
||||
setCookieHeader: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
default: dns,
|
||||
}));
|
||||
|
||||
vi.mock("node:net", () => ({
|
||||
default: net,
|
||||
}));
|
||||
|
||||
vi.mock("follow-redirects", async () => {
|
||||
const { EventEmitter } = await import("node:events");
|
||||
const { Readable } = await import("node:stream");
|
||||
|
||||
function Agent(opts) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
function makeRequest() {
|
||||
return (url, params, cb) => {
|
||||
const req = new EventEmitter();
|
||||
state.lastRequestParams = params;
|
||||
state.lastWrittenBody = null;
|
||||
req.write = vi.fn((chunk) => {
|
||||
state.lastWrittenBody = chunk;
|
||||
});
|
||||
req.end = vi.fn(() => {
|
||||
state.lastAgentOptions = params?.agent?.opts ?? null;
|
||||
if (state.error) {
|
||||
req.emit("error", state.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = new Readable({
|
||||
read() {
|
||||
this.push(state.response.body);
|
||||
this.push(null);
|
||||
},
|
||||
});
|
||||
res.statusCode = state.response.statusCode;
|
||||
res.headers = state.response.headers;
|
||||
cb(res);
|
||||
});
|
||||
return req;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
http: { request: makeRequest(), Agent },
|
||||
https: { request: makeRequest(), Agent },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
}));
|
||||
|
||||
vi.mock("./cookie-jar", () => cookieJar);
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
|
||||
describe("utils/proxy/http cachedRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.error = null;
|
||||
state.response = {
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Buffer.from(""),
|
||||
};
|
||||
state.lastAgentOptions = null;
|
||||
state.lastRequestParams = null;
|
||||
state.lastWrittenBody = null;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns cached values without calling httpProxy", async () => {
|
||||
cache.get.mockReturnValueOnce({ ok: true });
|
||||
const httpMod = await import("./http");
|
||||
const spy = vi.spyOn(httpMod, "httpProxy");
|
||||
|
||||
const data = await httpMod.cachedRequest("http://example.com");
|
||||
|
||||
expect(data).toEqual({ ok: true });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("parses json buffer responses and caches the result", async () => {
|
||||
cache.get.mockReturnValueOnce(null);
|
||||
state.response.body = Buffer.from('{"a":1}');
|
||||
const httpMod = await import("./http");
|
||||
|
||||
const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua");
|
||||
|
||||
expect(data).toEqual({ a: 1 });
|
||||
expect(cache.put).toHaveBeenCalledWith("http://example.com/data", { a: 1 }, 1 * 1000 * 60);
|
||||
});
|
||||
|
||||
it("falls back to string when cachedRequest cannot parse json", async () => {
|
||||
cache.get.mockReturnValueOnce(null);
|
||||
state.response.body = Buffer.from("not-json");
|
||||
const httpMod = await import("./http");
|
||||
|
||||
const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua");
|
||||
|
||||
expect(data).toBe("not-json");
|
||||
expect(logger.debug).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("utils/proxy/http homepageDNSLookupFn", () => {
|
||||
const getLookupFn = async () => {
|
||||
const httpMod = await import("./http");
|
||||
await httpMod.httpProxy("http://example.com");
|
||||
expect(state.lastAgentOptions?.lookup).toEqual(expect.any(Function));
|
||||
return state.lastAgentOptions.lookup;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.error = null;
|
||||
state.lastAgentOptions = null;
|
||||
net.isIP.mockReturnValue(0);
|
||||
dns.lookup.mockImplementation((hostname, options, cb) => cb(null, "127.0.0.1", 4));
|
||||
dns.resolve4.mockImplementation((hostname, cb) => cb(null, ["127.0.0.1"]));
|
||||
dns.resolve6.mockImplementation((hostname, cb) => cb(null, ["::1"]));
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("short-circuits when hostname is already an IP (all=false)", async () => {
|
||||
const lookup = await getLookupFn();
|
||||
net.isIP.mockReturnValueOnce(4);
|
||||
const cb = vi.fn();
|
||||
|
||||
lookup("1.2.3.4", cb);
|
||||
|
||||
expect(dns.lookup).not.toHaveBeenCalled();
|
||||
expect(cb).toHaveBeenCalledWith(null, "1.2.3.4", 4);
|
||||
});
|
||||
|
||||
it("short-circuits when hostname is already an IP (all=true)", async () => {
|
||||
const lookup = await getLookupFn();
|
||||
net.isIP.mockReturnValueOnce(6);
|
||||
const cb = vi.fn();
|
||||
|
||||
lookup("::1", { all: true }, cb);
|
||||
|
||||
expect(dns.lookup).not.toHaveBeenCalled();
|
||||
expect(cb).toHaveBeenCalledWith(null, [{ address: "::1", family: 6 }]);
|
||||
});
|
||||
|
||||
it("uses dns.lookup when it succeeds (2-argument form)", async () => {
|
||||
const lookup = await getLookupFn();
|
||||
const cb = vi.fn();
|
||||
|
||||
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(null, "10.0.0.1", 4));
|
||||
lookup("example.com", cb);
|
||||
|
||||
expect(dns.lookup).toHaveBeenCalledWith("example.com", {}, expect.any(Function));
|
||||
expect(dns.resolve4).not.toHaveBeenCalled();
|
||||
expect(dns.resolve6).not.toHaveBeenCalled();
|
||||
expect(cb).toHaveBeenCalledWith(null, "10.0.0.1", 4);
|
||||
});
|
||||
|
||||
it("does not fall back for non-ENOTFOUND/EAI_NONAME lookup errors", async () => {
|
||||
const lookup = await getLookupFn();
|
||||
const cb = vi.fn();
|
||||
const err = Object.assign(new Error("temporary"), { code: "EAI_AGAIN" });
|
||||
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(err));
|
||||
|
||||
lookup("example.com", { all: true }, cb);
|
||||
|
||||
expect(dns.resolve4).not.toHaveBeenCalled();
|
||||
expect(dns.resolve6).not.toHaveBeenCalled();
|
||||
expect(cb).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
it("falls back to resolve4 when lookup fails with ENOTFOUND and family=4", async () => {
|
||||
const lookup = await getLookupFn();
|
||||
const cb = vi.fn();
|
||||
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
|
||||
|
||||
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
|
||||
dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["1.1.1.1"]));
|
||||
|
||||
lookup("example.com", { family: 4, all: true }, cb);
|
||||
|
||||
expect(dns.resolve4).toHaveBeenCalledWith("example.com", expect.any(Function));
|
||||
expect(dns.resolve6).not.toHaveBeenCalled();
|
||||
expect(cb).toHaveBeenCalledWith(null, [{ address: "1.1.1.1", family: 4 }]);
|
||||
expect(logger.debug).toHaveBeenCalledWith("DNS fallback to c-ares resolver succeeded for %s", "example.com");
|
||||
});
|
||||
|
||||
it("falls back to resolve6 when lookup fails with ENOTFOUND and family=6", async () => {
|
||||
const lookup = await getLookupFn();
|
||||
const cb = vi.fn();
|
||||
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
|
||||
|
||||
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
|
||||
dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["::1"]));
|
||||
|
||||
lookup("example.com", 6, cb);
|
||||
|
||||
expect(dns.lookup).toHaveBeenCalledWith("example.com", { family: 6 }, expect.any(Function));
|
||||
expect(dns.resolve4).not.toHaveBeenCalled();
|
||||
expect(dns.resolve6).toHaveBeenCalledWith("example.com", expect.any(Function));
|
||||
expect(cb).toHaveBeenCalledWith(null, ["::1"], 6);
|
||||
});
|
||||
|
||||
it("tries resolve4 then resolve6 when lookup fails and no family is specified", async () => {
|
||||
const lookup = await getLookupFn();
|
||||
const cb = vi.fn();
|
||||
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
|
||||
|
||||
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
|
||||
dns.resolve4.mockImplementationOnce((hostname, resolveCb) =>
|
||||
resolveCb(Object.assign(new Error("v4 failed"), { code: "EAI_FAIL" })),
|
||||
);
|
||||
dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["::1"]));
|
||||
|
||||
lookup("example.com", { all: true }, cb);
|
||||
|
||||
expect(dns.resolve4).toHaveBeenCalledWith("example.com", expect.any(Function));
|
||||
expect(dns.resolve6).toHaveBeenCalledWith("example.com", expect.any(Function));
|
||||
expect(cb).toHaveBeenCalledWith(null, [{ address: "::1", family: 6 }]);
|
||||
});
|
||||
|
||||
it("returns ENOTFOUND when fallback resolver returns no addresses", async () => {
|
||||
const lookup = await getLookupFn();
|
||||
const cb = vi.fn();
|
||||
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
|
||||
|
||||
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
|
||||
dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, []));
|
||||
|
||||
lookup("example.com", { family: 4, all: true }, cb);
|
||||
|
||||
const err = cb.mock.calls[0][0];
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe("ENOTFOUND");
|
||||
expect(dns.resolve6).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns resolve error when fallback resolver fails", async () => {
|
||||
const lookup = await getLookupFn();
|
||||
const cb = vi.fn();
|
||||
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
|
||||
const resolveErr = Object.assign(new Error("resolver down"), { code: "EAI_FAIL" });
|
||||
|
||||
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
|
||||
dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(resolveErr));
|
||||
|
||||
lookup("example.com", { family: 4, all: true }, cb);
|
||||
|
||||
expect(cb).toHaveBeenCalledWith(resolveErr);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
"DNS fallback failed for %s: lookup error=%s, resolve error=%s",
|
||||
"example.com",
|
||||
"ENOTFOUND",
|
||||
"EAI_FAIL",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("utils/proxy/http httpProxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.error = null;
|
||||
state.response = {
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Buffer.from("ok"),
|
||||
};
|
||||
state.lastAgentOptions = null;
|
||||
state.lastRequestParams = null;
|
||||
state.lastWrittenBody = null;
|
||||
process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = "";
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("sets content-length and writes request bodies", async () => {
|
||||
const httpMod = await import("./http");
|
||||
const body = "abc";
|
||||
|
||||
const [status] = await httpMod.httpProxy("http://example.com", { method: "POST", body, headers: {} });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(state.lastRequestParams.headers["content-length"]).toBe(3);
|
||||
expect(state.lastWrittenBody).toBe(body);
|
||||
});
|
||||
|
||||
it("installs a beforeRedirect hook and updates the cookie jar", async () => {
|
||||
const httpMod = await import("./http");
|
||||
await httpMod.httpProxy("http://example.com");
|
||||
|
||||
expect(state.lastRequestParams.beforeRedirect).toEqual(expect.any(Function));
|
||||
expect(cookieJar.setCookieHeader).toHaveBeenCalled();
|
||||
expect(cookieJar.addCookieToJar).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates cookies during redirects via beforeRedirect", async () => {
|
||||
const httpMod = await import("./http");
|
||||
await httpMod.httpProxy("http://example.com");
|
||||
|
||||
state.lastRequestParams.beforeRedirect(
|
||||
{ href: "http://example.com/redirect" },
|
||||
{ headers: { "set-cookie": ["a=b"] } },
|
||||
);
|
||||
|
||||
expect(cookieJar.addCookieToJar).toHaveBeenCalledWith("http://example.com/redirect", { "set-cookie": ["a=b"] });
|
||||
expect(cookieJar.setCookieHeader).toHaveBeenCalledWith("http://example.com/redirect", expect.any(Object));
|
||||
});
|
||||
|
||||
it("supports gzip-compressed responses", async () => {
|
||||
const { gzipSync } = await import("node:zlib");
|
||||
state.response.headers["content-encoding"] = "gzip";
|
||||
state.response.body = gzipSync(Buffer.from("hello"));
|
||||
|
||||
const httpMod = await import("./http");
|
||||
const [, , data] = await httpMod.httpProxy("http://example.com");
|
||||
|
||||
expect(Buffer.from(data).toString()).toBe("hello");
|
||||
});
|
||||
|
||||
it("logs when gzip decoding emits an error", async () => {
|
||||
const { PassThrough } = await import("node:stream");
|
||||
|
||||
vi.doMock("node:zlib", async () => {
|
||||
const actual = await vi.importActual("node:zlib");
|
||||
return {
|
||||
...actual,
|
||||
createUnzip: () => {
|
||||
const pt = new PassThrough();
|
||||
pt.on("pipe", () => {
|
||||
queueMicrotask(() => {
|
||||
pt.emit("error", new Error("bad gzip"));
|
||||
pt.end();
|
||||
});
|
||||
});
|
||||
return pt;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const httpMod = await import("./http");
|
||||
|
||||
state.response.headers["content-encoding"] = "gzip";
|
||||
state.response.body = Buffer.from("hello");
|
||||
|
||||
await httpMod.httpProxy("http://example.com");
|
||||
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
|
||||
vi.unmock("node:zlib");
|
||||
});
|
||||
|
||||
it("applies strict IPv4 agent options when HOMEPAGE_PROXY_DISABLE_IPV6 is true", async () => {
|
||||
process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = "true";
|
||||
const httpMod = await import("./http");
|
||||
|
||||
await httpMod.httpProxy("http://example.com");
|
||||
|
||||
expect(state.lastAgentOptions.family).toBe(4);
|
||||
expect(state.lastAgentOptions.autoSelectFamily).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the https agent with rejectUnauthorized=false for https:// URLs", async () => {
|
||||
const httpMod = await import("./http");
|
||||
|
||||
await httpMod.httpProxy("https://example.com");
|
||||
|
||||
expect(state.lastAgentOptions.rejectUnauthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a sanitized error response when the request fails", async () => {
|
||||
state.error = Object.assign(new Error("boom"), { code: "EHOSTUNREACH" });
|
||||
const httpMod = await import("./http");
|
||||
|
||||
const [status, contentType, data] = await httpMod.httpProxy("http://example.com/?apikey=secret");
|
||||
|
||||
expect(status).toBe(500);
|
||||
expect(contentType).toBe("application/json");
|
||||
expect(data.error.message).toBe("boom");
|
||||
expect(data.error.url).toContain("apikey=***");
|
||||
});
|
||||
});
|
||||
49
src/utils/proxy/use-widget-api.test.js
Normal file
49
src/utils/proxy/use-widget-api.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import useWidgetAPI from "./use-widget-api";
|
||||
|
||||
describe("utils/proxy/use-widget-api", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("formats the proxy url and passes refreshInterval when provided in options", () => {
|
||||
useSWR.mockReturnValue({ data: { ok: true }, error: undefined, mutate: "m" });
|
||||
|
||||
const widget = { service_group: "g", service_name: "s", index: 0 };
|
||||
const result = useWidgetAPI(widget, "status", { refreshInterval: 123, foo: "bar" });
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/services/proxy?"),
|
||||
expect.objectContaining({ refreshInterval: 123 }),
|
||||
);
|
||||
expect(result.data).toEqual({ ok: true });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.mutate).toBe("m");
|
||||
});
|
||||
|
||||
it("returns data.error as the top-level error", () => {
|
||||
const dataError = { message: "nope" };
|
||||
useSWR.mockReturnValue({ data: { error: dataError }, error: undefined, mutate: vi.fn() });
|
||||
|
||||
const widget = { service_group: "g", service_name: "s", index: 0 };
|
||||
const result = useWidgetAPI(widget, "status", {});
|
||||
|
||||
expect(result.error).toBe(dataError);
|
||||
});
|
||||
|
||||
it("disables the request when endpoint is an empty string", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined, mutate: vi.fn() });
|
||||
|
||||
const widget = { service_group: "g", service_name: "s", index: 0 };
|
||||
useWidgetAPI(widget, "");
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith(null, {});
|
||||
});
|
||||
});
|
||||
44
src/utils/proxy/validate-widget-data.test.js
Normal file
44
src/utils/proxy/validate-widget-data.test.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { loggerError } = vi.hoisted(() => ({
|
||||
loggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => ({
|
||||
error: loggerError,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
test: {
|
||||
mappings: {
|
||||
foo: {
|
||||
endpoint: "foo",
|
||||
validate: ["a", "b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import validateWidgetData from "./validate-widget-data";
|
||||
|
||||
describe("utils/proxy/validate-widget-data", () => {
|
||||
it("returns false when buffer JSON cannot be parsed", () => {
|
||||
expect(validateWidgetData({ type: "test" }, "foo", Buffer.from("not json"))).toBe(false);
|
||||
expect(loggerError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries parsing after stripping whitespace (e.g. vertical tab) and validates required keys", () => {
|
||||
// JSON.parse allows only a subset of whitespace; vertical tab triggers a parse error.
|
||||
const data = Buffer.from(`{\u000B"a": 1, "b": 2}`);
|
||||
expect(validateWidgetData({ type: "test" }, "foo", data)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when required validate keys are missing", () => {
|
||||
expect(validateWidgetData({ type: "test" }, "foo", Buffer.from(JSON.stringify({ a: 1 })))).toBe(false);
|
||||
expect(loggerError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user