mirror of
https://github.com/gethomepage/homepage.git
synced 2026-04-07 10:41:20 -07:00
Chore: homepage tests (#6278)
This commit is contained in:
@@ -18,6 +18,11 @@ export default async function credentialedProxyHandler(req, res, map) {
|
||||
if (group && service) {
|
||||
const widget = await getServiceWidget(group, service, index);
|
||||
|
||||
if (!widget) {
|
||||
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
|
||||
return res.status(400).json({ error: "Invalid proxy service type" });
|
||||
}
|
||||
|
||||
if (!widgets?.[widget.type]?.api) {
|
||||
return res.status(403).json({ error: "Service does not support API calls" });
|
||||
}
|
||||
|
||||
404
src/utils/proxy/handlers/credentialed.test.js
Normal file
404
src/utils/proxy/handlers/credentialed.test.js
Normal file
@@ -0,0 +1,404 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { httpProxy } = vi.hoisted(() => ({ httpProxy: vi.fn() }));
|
||||
const { validateWidgetData } = vi.hoisted(() => ({ validateWidgetData: vi.fn(() => true) }));
|
||||
const { getServiceWidget } = vi.hoisted(() => ({ getServiceWidget: vi.fn() }));
|
||||
const { getSettings } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(() => ({ providers: { finnhub: "finnhub-token" } })),
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("utils/proxy/http", () => ({ httpProxy }));
|
||||
vi.mock("utils/proxy/validate-widget-data", () => ({ default: validateWidgetData }));
|
||||
vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget }));
|
||||
vi.mock("utils/config/config", () => ({ getSettings }));
|
||||
|
||||
// Keep the widget registry minimal so the test doesn't import the whole widget graph.
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
coinmarketcap: { api: "{url}/{endpoint}" },
|
||||
gotify: { api: "{url}/{endpoint}" },
|
||||
plantit: { api: "{url}/{endpoint}" },
|
||||
myspeed: { api: "{url}/{endpoint}" },
|
||||
esphome: { api: "{url}/{endpoint}" },
|
||||
wgeasy: { api: "{url}/{endpoint}" },
|
||||
linkwarden: { api: "{url}/api/v1/{endpoint}" },
|
||||
miniflux: { api: "{url}/{endpoint}" },
|
||||
nextcloud: { api: "{url}/ocs/v2.php/apps/serverinfo/api/v1/{endpoint}" },
|
||||
paperlessngx: { api: "{url}/api/{endpoint}" },
|
||||
proxmox: { api: "{url}/api2/json/{endpoint}" },
|
||||
truenas: { api: "{url}/api/v2.0/{endpoint}" },
|
||||
proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" },
|
||||
checkmk: { api: "{url}/{endpoint}" },
|
||||
stocks: { api: "{url}/{endpoint}" },
|
||||
speedtest: { api: "{url}/{endpoint}" },
|
||||
tubearchivist: { api: "{url}/{endpoint}" },
|
||||
autobrr: { api: "{url}/{endpoint}" },
|
||||
jellystat: { api: "{url}/{endpoint}" },
|
||||
trilium: { api: "{url}/{endpoint}" },
|
||||
gitlab: { api: "{url}/{endpoint}" },
|
||||
azuredevops: { api: "{url}/{endpoint}" },
|
||||
glances: { api: "{url}/{endpoint}" },
|
||||
withheaders: { api: "{url}/{endpoint}", headers: { "X-Widget": "1" } },
|
||||
},
|
||||
}));
|
||||
|
||||
import credentialedProxyHandler from "./credentialed";
|
||||
|
||||
function createMockRes() {
|
||||
const res = {
|
||||
headers: {},
|
||||
statusCode: undefined,
|
||||
body: undefined,
|
||||
setHeader: (k, v) => {
|
||||
res.headers[k] = v;
|
||||
},
|
||||
status: (code) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json: (data) => {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
send: (data) => {
|
||||
res.body = data;
|
||||
return res;
|
||||
},
|
||||
end: () => res,
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("utils/proxy/handlers/credentialed", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
validateWidgetData.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("returns 400 when group/service are missing", async () => {
|
||||
const req = { method: "GET", query: { endpoint: "e", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
|
||||
it("returns 400 when the widget cannot be resolved", async () => {
|
||||
getServiceWidget.mockResolvedValue(false);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
|
||||
it("returns 403 when the widget type does not support API calls", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "noapi", url: "http://example", key: "token" });
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Service does not support API calls" });
|
||||
});
|
||||
|
||||
it("uses Bearer auth for linkwarden widgets", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalled();
|
||||
const [, params] = httpProxy.mock.calls[0];
|
||||
expect(params.headers.Authorization).toBe("Bearer token");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("uses NC-Token auth for nextcloud widgets when key is provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "nextcloud", url: "http://example", key: "nc-token" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "status", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers["NC-Token"]).toBe("nc-token");
|
||||
expect(params.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses basic auth for nextcloud when key is not provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "nextcloud", url: "http://example", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "status", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("uses basic auth for truenas when key is not provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "truenas", url: "http://nas", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "system/info", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("uses Bearer auth for truenas when key is provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "truenas", url: "http://nas", key: "k" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "system/info", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toBe("Bearer k");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ type: "paperlessngx", url: "http://x", key: "k" }, { Authorization: "Token k" }],
|
||||
[
|
||||
{ type: "paperlessngx", url: "http://x", username: "u", password: "p" },
|
||||
{ Authorization: expect.stringMatching(/^Basic /) },
|
||||
],
|
||||
])("sets paperlessngx auth mode for %o", async (widget, expected) => {
|
||||
getServiceWidget.mockResolvedValue(widget);
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "documents", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers).toEqual(expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("uses basic auth for esphome when username/password are provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "esphome", url: "http://x", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "e", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("uses basic auth for wgeasy when username/password are provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "wgeasy", url: "http://x", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "e", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("covers additional auth/header modes for common widgets", async () => {
|
||||
const cases = [
|
||||
[{ type: "coinmarketcap", url: "http://x", key: "k" }, { "X-CMC_PRO_API_KEY": "k" }],
|
||||
[{ type: "gotify", url: "http://x", key: "k" }, { "X-gotify-Key": "k" }],
|
||||
[{ type: "plantit", url: "http://x", key: "k" }, { Key: "k" }],
|
||||
[{ type: "myspeed", url: "http://x", password: "p" }, { Password: "p" }],
|
||||
[{ type: "proxmox", url: "http://x", username: "u", password: "p" }, { Authorization: "PVEAPIToken=u=p" }],
|
||||
[{ type: "autobrr", url: "http://x", key: "k" }, { "X-API-Token": "k" }],
|
||||
[{ type: "jellystat", url: "http://x", key: "k" }, { "X-API-Token": "k" }],
|
||||
[{ type: "tubearchivist", url: "http://x", key: "k" }, { Authorization: "Token k" }],
|
||||
[{ type: "miniflux", url: "http://x", key: "k" }, { "X-Auth-Token": "k" }],
|
||||
[{ type: "trilium", url: "http://x", key: "k" }, { Authorization: "k" }],
|
||||
[{ type: "gitlab", url: "http://x", key: "k" }, { "PRIVATE-TOKEN": "k" }],
|
||||
[{ type: "speedtest", url: "http://x", key: "k" }, { Authorization: "Bearer k" }],
|
||||
[
|
||||
{ type: "azuredevops", url: "http://x", key: "k" },
|
||||
{ Authorization: `Basic ${Buffer.from("$:k").toString("base64")}` },
|
||||
],
|
||||
[
|
||||
{ type: "glances", url: "http://x", username: "u", password: "p" },
|
||||
{ Authorization: expect.stringMatching(/^Basic /) },
|
||||
],
|
||||
[{ type: "wgeasy", url: "http://x", password: "p" }, { Authorization: "p" }],
|
||||
[{ type: "esphome", url: "http://x", key: "cookie" }, { Cookie: "authenticated=cookie" }],
|
||||
];
|
||||
|
||||
for (const [widget, expected] of cases) {
|
||||
getServiceWidget.mockResolvedValue(widget);
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "e", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers).toEqual(expect.objectContaining(expected));
|
||||
}
|
||||
});
|
||||
|
||||
it("merges registry/widget/request headers and falls back to X-API-Key for unknown types", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "withheaders",
|
||||
url: "http://example",
|
||||
key: "k",
|
||||
headers: { "X-From-Widget": "2" },
|
||||
});
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = {
|
||||
method: "GET",
|
||||
query: { group: "g", service: "s", endpoint: "collections", index: 0 },
|
||||
extraHeaders: { "X-From-Req": "3" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers).toEqual(
|
||||
expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Widget": "1",
|
||||
"X-From-Widget": "2",
|
||||
"X-From-Req": "3",
|
||||
"X-API-Key": "k",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets PBSAPIToken auth and removes content-type for proxmoxbackupserver", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "proxmoxbackupserver",
|
||||
url: "http://pbs",
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "nodes", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers["Content-Type"]).toBeUndefined();
|
||||
expect(params.headers.Authorization).toBe("PBSAPIToken=u:p");
|
||||
});
|
||||
|
||||
it("uses checkmk's Bearer username password auth format", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "checkmk", url: "http://checkmk", username: "u", password: "p" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = {
|
||||
method: "GET",
|
||||
query: { group: "g", service: "s", endpoint: "domain-types/host_config/collections/all", index: 0 },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers.Accept).toBe("application/json");
|
||||
expect(params.headers.Authorization).toBe("Bearer u p");
|
||||
});
|
||||
|
||||
it("injects the configured finnhub provider token for stocks widgets", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "stocks", url: "http://stocks", provider: "finnhub" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "quote", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
const [, params] = httpProxy.mock.calls.at(-1);
|
||||
expect(params.headers["X-Finnhub-Token"]).toBe("finnhub-token");
|
||||
});
|
||||
|
||||
it("sanitizes embedded query params when a downstream error contains a url", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([500, "application/json", { error: { message: "oops", url: "http://bad" } }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections?apikey=secret", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error.url).toContain("apikey=***");
|
||||
});
|
||||
|
||||
it("ends the response for 204/304 statuses", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([204, "application/json", Buffer.from("")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
});
|
||||
|
||||
it("returns invalid data errors as 500 when validation fails on 200 responses", async () => {
|
||||
validateWidgetData.mockReturnValueOnce(false);
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error.message).toBe("Invalid data");
|
||||
expect(res.body.error.url).toContain("http://example/api/v1/collections");
|
||||
});
|
||||
|
||||
it("applies the response mapping function when provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
|
||||
httpProxy.mockResolvedValue([200, "application/json", { ok: true, value: 1 }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await credentialedProxyHandler(req, res, (data) => ({ ok: data.ok, v: data.value }));
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true, v: 1 });
|
||||
});
|
||||
});
|
||||
256
src/utils/proxy/handlers/generic.test.js
Normal file
256
src/utils/proxy/handlers/generic.test.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, validateWidgetData, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
validateWidgetData: vi.fn(() => true),
|
||||
logger: { debug: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
vi.mock("utils/proxy/validate-widget-data", () => ({
|
||||
default: validateWidgetData,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
testservice: {
|
||||
api: "{url}/{endpoint}",
|
||||
},
|
||||
customapi: {
|
||||
api: "{url}/{endpoint}",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import genericProxyHandler from "./generic";
|
||||
|
||||
describe("utils/proxy/handlers/generic", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
validateWidgetData.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("returns 403 when the service widget type does not define an API mapping", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "missing",
|
||||
url: "http://example",
|
||||
});
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Service does not support API calls" });
|
||||
});
|
||||
|
||||
it("replaces extra '?' characters in the endpoint with '&'", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "x?a=1?b=2", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(1);
|
||||
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://example/x?a=1&b=2");
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("preserves trailing slash for customapi widgets when widget.url ends with /", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "customapi",
|
||||
url: "http://example/",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "path", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://example/path/");
|
||||
});
|
||||
|
||||
it("uses widget.requestBody as a string when req.body is not provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
method: "POST",
|
||||
requestBody: "raw-body",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = { method: "POST", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy.mock.calls[0][1].body).toBe("raw-body");
|
||||
});
|
||||
|
||||
it("uses requestBody and basic auth headers when provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
method: "POST",
|
||||
username: "u",
|
||||
password: "p",
|
||||
requestBody: { hello: "world" },
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy.mock.calls[0][1].method).toBe("POST");
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toMatch(/^Basic /);
|
||||
expect(httpProxy.mock.calls[0][1].body).toBe(JSON.stringify({ hello: "world" }));
|
||||
});
|
||||
|
||||
it("sanitizes error urls embedded in successful payloads", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
{
|
||||
error: {
|
||||
url: "http://upstream.example/?apikey=secret",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api?apikey=secret", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.error.url).toContain("apikey=***");
|
||||
});
|
||||
|
||||
it("returns an Invalid data error when validation fails", async () => {
|
||||
validateWidgetData.mockReturnValue(false);
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", { bad: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.error.message).toBe("Invalid data");
|
||||
});
|
||||
|
||||
it("uses string requestBody as-is and prefers req.body over widget.requestBody", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
requestBody: '{"a":1}',
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
|
||||
|
||||
const req = {
|
||||
method: "POST",
|
||||
body: "override-body",
|
||||
query: { group: "g", service: "svc", endpoint: "api", index: "0" },
|
||||
};
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(1);
|
||||
expect(httpProxy.mock.calls[0][1].body).toBe("override-body");
|
||||
});
|
||||
|
||||
it("ends the response for 204/304 statuses", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([204, "application/json", Buffer.from("")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an HTTP Error object for status>=400 and stringifies buffer data", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([500, "application/json", Buffer.from("fail")]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api?apikey=secret", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body.error.message).toBe("HTTP Error");
|
||||
expect(res.body.error.url).toContain("apikey=***");
|
||||
expect(res.body.error.data).toBe("fail");
|
||||
});
|
||||
|
||||
it("returns 400 when group/service are missing", async () => {
|
||||
const req = { method: "GET", query: { endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
expect(logger.debug).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the response mapping function when provided", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
type: "testservice",
|
||||
url: "http://example",
|
||||
});
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", { ok: true }]);
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await genericProxyHandler(req, res, (data) => ({ mapped: data.ok }));
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ mapped: true });
|
||||
});
|
||||
});
|
||||
219
src/utils/proxy/handlers/jsonrpc.test.js
Normal file
219
src/utils/proxy/handlers/jsonrpc.test.js
Normal file
@@ -0,0 +1,219 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: { debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
rpcwidget: {
|
||||
api: "{url}/jsonrpc",
|
||||
mappings: {
|
||||
list: { endpoint: "test.method", params: [1, 2] },
|
||||
},
|
||||
},
|
||||
missingapi: {
|
||||
mappings: {
|
||||
list: { endpoint: "test.method", params: [1, 2] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("utils/proxy/handlers/jsonrpc sendJsonRpcRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends a JSON-RPC request and returns the response", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
return [
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: req.id, result: { ok: true } })),
|
||||
];
|
||||
});
|
||||
|
||||
const [status, contentType, data] = await sendJsonRpcRequest("http://rpc", "test.method", [1], {
|
||||
username: "u",
|
||||
password: "p",
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(contentType).toBe("application/json");
|
||||
expect(JSON.parse(data)).toEqual({ ok: true });
|
||||
expect(httpProxy).toHaveBeenCalledTimes(1);
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it("maps JSON-RPC error responses into a result=null error object", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
return [
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: req.id, result: null, error: { code: 123, message: "bad" } })),
|
||||
];
|
||||
});
|
||||
|
||||
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(JSON.parse(data)).toEqual({ result: null, error: { code: 123, message: "bad" } });
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer token");
|
||||
});
|
||||
|
||||
it("prefers Bearer auth when both basic credentials and a key are provided", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
return [
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: req.id, result: { ok: true } })),
|
||||
];
|
||||
});
|
||||
|
||||
const [, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, {
|
||||
username: "u",
|
||||
password: "p",
|
||||
key: "token",
|
||||
});
|
||||
|
||||
expect(JSON.parse(data)).toEqual({ ok: true });
|
||||
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer token");
|
||||
});
|
||||
|
||||
it("maps transport/parse failures into a JSON-RPC style error response", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("not-json")]);
|
||||
|
||||
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(JSON.parse(data)).toEqual({
|
||||
result: null,
|
||||
error: { code: expect.any(Number), message: expect.any(String) },
|
||||
});
|
||||
expect(logger.debug).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes id=null responses so the client can still receive a result", async () => {
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
expect(req.id).toBe(1);
|
||||
return [200, "application/json", Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: null, result: { ok: true } }))];
|
||||
});
|
||||
|
||||
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(JSON.parse(data)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("utils/proxy/handlers/jsonrpc proxy handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("looks up the widget, applies mappings, and returns JSON-RPC data", async () => {
|
||||
const { default: jsonrpcProxyHandler } = await import("./jsonrpc");
|
||||
|
||||
getServiceWidget.mockResolvedValue({ type: "rpcwidget", url: "http://rpc", key: "token" });
|
||||
httpProxy.mockImplementationOnce(async (_url, params) => {
|
||||
const req = JSON.parse(params.body);
|
||||
return [
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: req.id, result: { method: req.method, params: req.params } })),
|
||||
];
|
||||
});
|
||||
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "test.method", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await jsonrpcProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json).toEqual({ method: "test.method", params: [1, 2] });
|
||||
});
|
||||
|
||||
it("returns 403 when the widget does not support API calls", async () => {
|
||||
const { default: jsonrpcProxyHandler } = await import("./jsonrpc");
|
||||
|
||||
getServiceWidget.mockResolvedValue({ type: "missingapi", url: "http://rpc" });
|
||||
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "test.method", index: 0 } };
|
||||
const res = createMockRes();
|
||||
|
||||
await jsonrpcProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Service does not support API calls" });
|
||||
});
|
||||
|
||||
it("returns 400 for invalid requests without group/service", async () => {
|
||||
const { default: jsonrpcProxyHandler } = await import("./jsonrpc");
|
||||
|
||||
const req = { method: "GET", query: { endpoint: "test.method" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await jsonrpcProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("utils/proxy/handlers/jsonrpc unexpected errors", () => {
|
||||
it("returns 500 when the JSON-RPC client throws a non-JSONRPCErrorException", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("json-rpc-2.0", () => {
|
||||
class JSONRPCErrorException extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
class JSONRPCClient {
|
||||
constructor() {}
|
||||
|
||||
receive() {}
|
||||
|
||||
async request() {
|
||||
throw new Error("boom");
|
||||
}
|
||||
}
|
||||
|
||||
return { JSONRPCClient, JSONRPCErrorException };
|
||||
});
|
||||
|
||||
const { sendJsonRpcRequest } = await import("./jsonrpc");
|
||||
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
|
||||
|
||||
expect(status).toBe(500);
|
||||
expect(JSON.parse(data)).toEqual({ result: null, error: { code: 2, message: "Error: boom" } });
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,9 @@ export default async function synologyProxyHandler(req, res) {
|
||||
}
|
||||
|
||||
const serviceWidget = await getServiceWidget(group, service, index);
|
||||
if (!serviceWidget) {
|
||||
return res.status(400).json({ error: "Invalid proxy service type" });
|
||||
}
|
||||
const widget = widgets?.[serviceWidget.type];
|
||||
const mapping = widget?.mappings?.[endpoint];
|
||||
if (!widget.api || !mapping) {
|
||||
@@ -158,7 +161,8 @@ export default async function synologyProxyHandler(req, res) {
|
||||
let [status, contentType, data] = await httpProxy(url);
|
||||
if (status !== 200) {
|
||||
logger.debug("Error %d calling url %s", status, url);
|
||||
return res.status(status, data);
|
||||
if (contentType) res.setHeader("Content-Type", contentType);
|
||||
return res.status(status).send(data);
|
||||
}
|
||||
|
||||
let json = asJson(data);
|
||||
|
||||
380
src/utils/proxy/handlers/synology.test.js
Normal file
380
src/utils/proxy/handlers/synology.test.js
Normal file
@@ -0,0 +1,380 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||
const store = new Map();
|
||||
return {
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
cache: {
|
||||
get: vi.fn((k) => store.get(k)),
|
||||
put: vi.fn((k, v) => store.set(k, v)),
|
||||
del: vi.fn((k) => store.delete(k)),
|
||||
_reset: () => store.clear(),
|
||||
},
|
||||
logger: { debug: vi.fn(), warn: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
...cache,
|
||||
}));
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
}));
|
||||
vi.mock("utils/config/service-helpers", () => ({
|
||||
default: getServiceWidget,
|
||||
}));
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
vi.mock("widgets/widgets", () => ({
|
||||
default: {
|
||||
synology: {
|
||||
api: "{url}/webapi/{cgiPath}?api={apiName}&version={maxVersion}&method={apiMethod}",
|
||||
mappings: {
|
||||
download: { apiName: "SYNO.DownloadStation2.Task", apiMethod: "list" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import synologyProxyHandler from "./synology";
|
||||
|
||||
describe("utils/proxy/handlers/synology", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cache._reset();
|
||||
});
|
||||
|
||||
it("returns 400 when group/service are missing", async () => {
|
||||
const req = { query: { endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
|
||||
it("returns 400 when the widget cannot be resolved", async () => {
|
||||
getServiceWidget.mockResolvedValue(false);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Invalid proxy service type" });
|
||||
});
|
||||
|
||||
it("returns 403 when the endpoint is not mapped", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "nope", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Service does not support API calls" });
|
||||
});
|
||||
|
||||
it("calls the mapped API when api info is available and success is true", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 } } })),
|
||||
])
|
||||
// api call
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: true, data: { ok: true } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(2);
|
||||
expect(httpProxy.mock.calls[1][0]).toContain("/webapi/entry.cgi?api=SYNO.DownloadStation2.Task");
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body.toString()).data.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("caches api info lookups to avoid repeated query calls", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// first call info query
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 } } })),
|
||||
])
|
||||
// first call api
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))])
|
||||
// second call api only (info should be cached)
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res1 = createMockRes();
|
||||
const res2 = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res1);
|
||||
await synologyProxyHandler(req, res2);
|
||||
|
||||
expect(httpProxy).toHaveBeenCalledTimes(3);
|
||||
// second invocation should not re-fetch api info
|
||||
expect(httpProxy.mock.calls[2][0]).toContain("/webapi/entry.cgi?api=SYNO.DownloadStation2.Task");
|
||||
});
|
||||
|
||||
it("returns non-200 proxy responses as-is (with content-type)", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 } } })),
|
||||
])
|
||||
.mockResolvedValueOnce([503, "text/plain", Buffer.from("nope")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.headers["Content-Type"]).toBe("text/plain");
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(res.body).toEqual(Buffer.from("nope"));
|
||||
});
|
||||
|
||||
it("returns 400 when the API name is unrecognized", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ data: {} }))]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Unrecognized API name: SYNO.DownloadStation2.Task" });
|
||||
});
|
||||
|
||||
it("logs a warning when API info returns invalid JSON and treats the API name as unrecognized", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("{not json")]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: "Unrecognized API name: SYNO.DownloadStation2.Task" });
|
||||
});
|
||||
|
||||
it("includes a 2FA hint when authentication fails with a 403+ error code", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query for mapping api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
"SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 },
|
||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
// api call returns success false -> triggers login
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),
|
||||
])
|
||||
// info query for auth api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 } } })),
|
||||
])
|
||||
// login returns success false with 2fa-required code
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 403 } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual(expect.objectContaining({ code: 403, error: expect.stringContaining("2FA") }));
|
||||
});
|
||||
|
||||
it("handles non-200 login responses and surfaces a synology error code", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query for mapping api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
"SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 },
|
||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
// api call returns success false -> triggers login
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),
|
||||
])
|
||||
// info query for auth api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 } } })),
|
||||
])
|
||||
// login is non-200 => login() returns early
|
||||
.mockResolvedValueOnce([
|
||||
503,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 103 } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ code: 103, error: "The requested method does not exist." });
|
||||
});
|
||||
|
||||
it("attempts login and retries when the initial response is unsuccessful", async () => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query for mapping api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
"SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 },
|
||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
// api call returns success false
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),
|
||||
])
|
||||
// info query for auth api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 } } })),
|
||||
])
|
||||
// login success
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))])
|
||||
// retry still fails
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code: 106 } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ code: 106, error: "Session timeout." });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[102, "The requested API does not exist."],
|
||||
[104, "The requested version does not support the functionality."],
|
||||
[105, "The logged in session does not have permission."],
|
||||
[107, "Session interrupted by duplicated login."],
|
||||
[119, "Invalid session or SID not found."],
|
||||
])("maps synology error code %s to a friendly error", async (code, expected) => {
|
||||
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
|
||||
|
||||
httpProxy
|
||||
// info query for mapping api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
"SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 },
|
||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
// api call returns success false -> triggers login
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code } })),
|
||||
])
|
||||
// info query for auth api name
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ data: { "SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 } } })),
|
||||
])
|
||||
// login success
|
||||
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))])
|
||||
// retry still fails with the same code
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ success: false, error: { code } })),
|
||||
]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await synologyProxyHandler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.body).toEqual({ code, error: expected });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user