Chore: homepage tests (#6278)

This commit is contained in:
shamoon
2026-02-04 19:58:39 -08:00
committed by GitHub
parent 7d019185a3
commit 872a3600aa
558 changed files with 32606 additions and 84 deletions

View 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");
});
});

View 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");
});
});

View File

@@ -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" });
}

View 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 });
});
});

View 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 });
});
});

View 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();
});
});

View File

@@ -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);

View 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 });
});
});

View File

@@ -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,
];
}

View 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=***");
});
});

View 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, {});
});
});

View 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();
});
});