test: add widget proxy tests (batch 3)

This commit is contained in:
shamoon
2026-02-03 13:48:24 -08:00
parent 5bf187816a
commit 02c1435b5e
10 changed files with 766 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
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(),
error: vi.fn(),
},
}));
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: {
dockhand: {
api: "{url}/{endpoint}",
},
},
}));
import dockhandProxyHandler from "./proxy";
describe("widgets/dockhand/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retries after a 401 by logging in once", async () => {
getServiceWidget.mockResolvedValue({
type: "dockhand",
url: "http://dockhand/",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")])
.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]) // login
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]); // retry
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api/v1/status", index: "0" } };
const res = createMockRes();
await dockhandProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[1][0]).toBe("http://dockhand/api/auth/login");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("data"));
});
it("returns a sanitized error response for HTTP errors", async () => {
getServiceWidget.mockResolvedValue({
type: "dockhand",
url: "http://dockhand",
});
httpProxy.mockResolvedValueOnce([500, "application/json", Buffer.from("boom")]);
const req = {
method: "GET",
query: { group: "g", service: "svc", endpoint: "api/v1/status?token=abc", index: "0" },
};
const res = createMockRes();
await dockhandProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body.error.message).toBe("HTTP Error");
expect(res.body.error.url).toContain("token=***");
});
});

View File

@@ -0,0 +1,73 @@
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(),
error: vi.fn(),
},
};
});
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("memory-cache", () => ({
default: cache,
...cache,
}));
import homeboxProxyHandler from "./proxy";
describe("widgets/homebox/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache._reset();
});
it("logs in and returns group statistics + currency", async () => {
getServiceWidget.mockResolvedValue({
url: "http://homebox",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ token: "tok", expiresAt: new Date(Date.now() + 60_000).toISOString() })),
])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ totalItems: 1, totalUsers: 2 }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ currency: "USD" }))]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await homeboxProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[0][0]).toBe("http://homebox/api/v1/users/login");
expect(res.statusCode).toBe(200);
expect(res.body.currencyCode).toBe("USD");
expect(res.body.users).toBe(2);
});
});

View File

@@ -0,0 +1,96 @@
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(),
error: vi.fn(),
},
};
});
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("memory-cache", () => ({
default: cache,
...cache,
}));
vi.mock("widgets/widgets", () => ({
default: {
homebridge: {
api: "{url}/{endpoint}",
},
},
}));
import homebridgeProxyHandler from "./proxy";
describe("widgets/homebridge/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache._reset();
});
it("logs in and aggregates status, versions, plugin updates, and child bridge counts", async () => {
getServiceWidget.mockResolvedValue({ type: "homebridge", url: "http://hb", username: "u", password: "p" });
httpProxy
// login
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ access_token: "tok", expires_in: 3600 })),
{},
])
// status
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ status: "ok" })), {}])
// version
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ updateAvailable: true })), {}])
// child bridges
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify([{ status: "ok" }, { status: "down" }])),
{},
])
// plugins
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify([{ updateAvailable: true }, { updateAvailable: false }])),
{},
]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await homebridgeProxyHandler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
status: "ok",
updateAvailable: true,
plugins: { updatesAvailable: 1 },
childBridges: { running: 1, total: 2 },
});
});
});

View File

@@ -0,0 +1,79 @@
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: {
error: vi.fn(),
},
}));
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: {
jackett: {
api: "{url}/{endpoint}",
loginURL: "{url}/UI/Dashboard",
},
},
}));
import jackettProxyHandler from "./proxy";
describe("widgets/jackett/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches an auth cookie when password is set and passes it on requests", async () => {
getServiceWidget.mockResolvedValue({
type: "jackett",
url: "http://jackett",
password: "pw",
});
httpProxy
// login cookie fetch
.mockResolvedValueOnce([200, "text/plain", null, null, { headers: { Cookie: "c=1" } }])
// api call
.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
const req = { query: { group: "g", service: "svc", endpoint: "api/v2.0/indexers/all/results", index: "0" } };
const res = createMockRes();
await jackettProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(2);
expect(httpProxy.mock.calls[1][1].headers.Cookie).toBe("c=1");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("ok"));
});
it("returns 500 when cookie authentication fails", async () => {
getServiceWidget.mockResolvedValue({
type: "jackett",
url: "http://jackett",
password: "pw",
});
httpProxy.mockResolvedValueOnce([200, "text/plain", null, null, { headers: {} }]);
const req = { query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
const res = createMockRes();
await jackettProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Jackett" });
});
});

View File

@@ -0,0 +1,121 @@
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(),
error: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
import omadaProxyHandler from "./proxy";
describe("widgets/omada/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches controller info, logs in, selects site, and returns overview stats (v4)", async () => {
getServiceWidget.mockResolvedValue({
url: "http://omada",
username: "u",
password: "p",
site: "Default",
});
httpProxy
// controller info
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
])
// login
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
])
// sites list
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
])
// overview diagram
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({
errorCode: 0,
result: {
totalClientNum: 10,
connectedApNum: 2,
connectedGatewayNum: 1,
connectedSwitchNum: 3,
},
}),
])
// alert count
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await omadaProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(5);
expect(res.statusCode).toBe(null); // uses res.send directly without setting status
expect(res.body).toBe(
JSON.stringify({
connectedAp: 2,
activeUser: 10,
alerts: 4,
connectedGateways: 1,
connectedSwitches: 3,
}),
);
});
it("returns an error when the site is not found", async () => {
getServiceWidget.mockResolvedValue({ url: "http://omada", username: "u", password: "p", site: "Missing" });
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
])
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await omadaProxyHandler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body.error.message).toContain("Site Missing is not found");
});
});

View File

@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, cookieJar, logger } = vi.hoisted(() => ({
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
cookieJar: {
addCookieToJar: vi.fn(),
setCookieHeader: vi.fn(),
},
logger: {
debug: vi.fn(),
error: 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/cookie-jar", () => cookieJar);
vi.mock("widgets/widgets", () => ({
default: {
openmediavault: {
api: "{url}/rpc.php",
},
},
}));
import openmediavaultProxyHandler from "./proxy";
describe("widgets/openmediavault/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("logs in after a 401 and retries the RPC call", async () => {
getServiceWidget.mockResolvedValue({
type: "openmediavault",
url: "http://omv",
username: "u",
password: "p",
method: "foo.bar",
});
httpProxy
// initial rpc unauthorized
.mockResolvedValueOnce([401, "application/json", Buffer.from(JSON.stringify({ response: {} })), {}])
// login rpc
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ response: { authenticated: true } })),
{ "set-cookie": ["sid=1"] },
])
// retry rpc
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ response: { ok: true } })), {}]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await openmediavaultProxyHandler(req, res);
expect(cookieJar.addCookieToJar).toHaveBeenCalled();
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from(JSON.stringify({ response: { ok: true } })));
});
});

View File

@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { sendJsonRpcRequest, getServiceWidget, logger } = vi.hoisted(() => ({
sendJsonRpcRequest: vi.fn(),
getServiceWidget: vi.fn(),
logger: { debug: vi.fn() },
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/handlers/jsonrpc", () => ({
sendJsonRpcRequest,
}));
vi.mock("widgets/widgets", () => ({
default: {
openwrt: {
api: "{url}",
},
},
}));
import openwrtProxyHandler from "./proxy";
describe("widgets/openwrt/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("logs in and retries after an unauthorized response", async () => {
getServiceWidget.mockResolvedValue({ type: "openwrt", url: "http://openwrt", username: "u", password: "p" });
sendJsonRpcRequest
// initial call -> unauthorized
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ error: { code: -32002 } }))])
// login -> sets ubus token
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify([0, { ubus_rpc_session: "sess" }]))])
// retry system info -> ok
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify([0, { uptime: 1, load: [0, 131072, 0] }])),
]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await openwrtProxyHandler(req, res);
expect(sendJsonRpcRequest).toHaveBeenCalledTimes(3);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).cpuLoad).toBe("2.00");
});
});

View File

@@ -0,0 +1,41 @@
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(), error: vi.fn() },
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
import photoprismProxyHandler from "./proxy";
describe("widgets/photoprism/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("supports bearer-token auth and returns config count", async () => {
getServiceWidget.mockResolvedValue({ url: "http://pp", key: "k" });
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ config: { count: 123 } }))]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await photoprismProxyHandler(req, res);
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer k");
expect(res.statusCode).toBe(200);
expect(res.body).toBe(123);
});
});

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, cache, xml2json, logger } = vi.hoisted(() => {
const store = new Map();
return {
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
cache: {
get: vi.fn((k) => (store.has(k) ? store.get(k) : null)),
put: vi.fn((k, v) => store.set(k, v)),
del: vi.fn((k) => store.delete(k)),
_reset: () => store.clear(),
},
xml2json: vi.fn((xml) => {
if (xml === "sessions") return JSON.stringify({ MediaContainer: { _attributes: { size: "2" } } });
if (xml === "libraries")
return JSON.stringify({
MediaContainer: {
Directory: [
{ _attributes: { type: "movie", key: "1" } },
{ _attributes: { type: "show", key: "2" } },
{ _attributes: { type: "artist", key: "3" } },
],
},
});
if (xml === "movies") return JSON.stringify({ MediaContainer: { _attributes: { size: "10" } } });
if (xml === "tv") return JSON.stringify({ MediaContainer: { _attributes: { totalSize: "20" } } });
if (xml === "albums") return JSON.stringify({ MediaContainer: { _attributes: { size: "30" } } });
return JSON.stringify({ MediaContainer: { _attributes: { size: "0" } } });
}),
logger: { debug: vi.fn(), error: vi.fn() },
};
});
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("memory-cache", () => ({
default: cache,
...cache,
}));
vi.mock("xml-js", () => ({
xml2json,
}));
vi.mock("widgets/widgets", () => ({
default: {
plex: {
api: "{url}{endpoint}",
},
},
}));
import plexProxyHandler from "./proxy";
describe("widgets/plex/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache._reset();
});
it("fetches sessions and library counts, caching intermediate results", async () => {
getServiceWidget.mockResolvedValue({ type: "plex", url: "http://plex" });
httpProxy
// sessions
.mockResolvedValueOnce([200, "application/xml", Buffer.from("sessions")])
// libraries
.mockResolvedValueOnce([200, "application/xml", Buffer.from("libraries")])
// movies
.mockResolvedValueOnce([200, "application/xml", Buffer.from("movies")])
// tv
.mockResolvedValueOnce([200, "application/xml", Buffer.from("tv")])
// albums
.mockResolvedValueOnce([200, "application/xml", Buffer.from("albums")]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await plexProxyHandler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ streams: "2", albums: 30, movies: 10, tv: 20 });
expect(cache.put).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,48 @@
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: { error: vi.fn() },
}));
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: {
rutorrent: {
api: "{url}",
},
},
}));
import rutorrentProxyHandler from "./proxy";
describe("widgets/rutorrent/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("parses torrent list data into an array", async () => {
getServiceWidget.mockResolvedValue({ type: "rutorrent", url: "http://ru", username: "u", password: "p" });
httpProxy.mockResolvedValueOnce([200, "application/json", JSON.stringify({ t: { hash1: Array(34).fill(0) } })]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await rutorrentProxyHandler(req, res);
expect(res.statusCode).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body[0]["d.get_name"]).toBe(0);
});
});