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

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