diff --git a/src/widgets/apcups/proxy.test.js b/src/widgets/apcups/proxy.test.js new file mode 100644 index 000000000..ae5fde181 --- /dev/null +++ b/src/widgets/apcups/proxy.test.js @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +function encodeLine(line) { + const buf = Buffer.alloc(2 + line.length); + buf.writeUInt16BE(line.length, 0); + buf.write(line, 2, "ascii"); + return buf; +} + +const { getServiceWidget, logger } = vi.hoisted(() => ({ + getServiceWidget: vi.fn(), + logger: { debug: vi.fn(), error: vi.fn() }, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +vi.mock("node:net", () => { + class FakeSocket { + constructor() { + this._handlers = new Map(); + } + setTimeout() {} + connect() { + queueMicrotask(() => this._emit("connect")); + } + on(event, cb) { + const set = this._handlers.get(event) ?? new Set(); + set.add(cb); + this._handlers.set(event, set); + } + write() { + const response = Buffer.concat([ + encodeLine("STATUS : ONLINE"), + encodeLine("LOADPCT : 10.0"), + encodeLine("BCHARGE : 99.0"), + encodeLine("TIMELEFT : 12.3"), + encodeLine("END APC"), + Buffer.from([0x00, 0x00]), + ]); + queueMicrotask(() => this._emit("data", response)); + } + end() {} + destroy() {} + _emit(event, payload) { + const set = this._handlers.get(event); + if (!set) return; + set.forEach((cb) => cb(payload)); + } + } + + return { + default: { + Socket: FakeSocket, + }, + }; +}); + +import apcupsProxyHandler from "./proxy"; + +describe("widgets/apcups/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("parses the APCUPSD status response into JSON", async () => { + getServiceWidget.mockResolvedValue({ url: "http://127.0.0.1:3551" }); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await apcupsProxyHandler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + status: "ONLINE", + load: "10.0", + bcharge: "99.0", + timeleft: "12.3", + }); + }); +}); diff --git a/src/widgets/audiobookshelf/proxy.test.js b/src/widgets/audiobookshelf/proxy.test.js new file mode 100644 index 000000000..072618d64 --- /dev/null +++ b/src/widgets/audiobookshelf/proxy.test.js @@ -0,0 +1,67 @@ +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: { + audiobookshelf: { + api: "{url}/api/{endpoint}", + }, + }, +})); + +import audiobookshelfProxyHandler from "./proxy"; + +describe("widgets/audiobookshelf/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("retrieves libraries and per-library stats", async () => { + getServiceWidget.mockResolvedValue({ type: "audiobookshelf", url: "http://abs", key: "k" }); + + httpProxy + .mockResolvedValueOnce([ + 200, + "application/json", + Buffer.from( + JSON.stringify({ + libraries: [ + { id: "l1", name: "A" }, + { id: "l2", name: "B" }, + ], + }), + ), + ]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ total: 1 }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ total: 2 }))]); + + const req = { query: { group: "g", service: "svc", endpoint: "libraries", index: "0" } }; + const res = createMockRes(); + + await audiobookshelfProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer k"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([ + { id: "l1", name: "A", stats: { total: 1 } }, + { id: "l2", name: "B", stats: { total: 2 } }, + ]); + }); +}); diff --git a/src/widgets/truenas/proxy.test.js b/src/widgets/truenas/proxy.test.js new file mode 100644 index 000000000..b56c76060 --- /dev/null +++ b/src/widgets/truenas/proxy.test.js @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getServiceWidget, validateWidgetData, logger } = vi.hoisted(() => ({ + getServiceWidget: vi.fn(), + validateWidgetData: vi.fn(() => true), + logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); +vi.mock("utils/proxy/validate-widget-data", () => ({ + default: validateWidgetData, +})); +vi.mock("utils/proxy/handlers/credentialed", () => ({ + default: vi.fn(), +})); +vi.mock("widgets/widgets", () => ({ + default: { + truenas: { + wsAPI: "{url}/websocket", + mappings: { + stats: { endpoint: "stats", wsMethod: "system.info" }, + }, + }, + }, +})); + +vi.mock("ws", () => { + class FakeWebSocket { + constructor(url) { + this.url = url; + this._handlers = new Map(); + } + on(event, cb) { + const set = this._handlers.get(event) ?? new Set(); + set.add(cb); + this._handlers.set(event, set); + if (event === "open") { + queueMicrotask(() => cb()); + } + } + off(event, cb) { + const set = this._handlers.get(event); + if (set) set.delete(cb); + } + send(payload) { + const msg = JSON.parse(payload); + let result = true; + if (msg.method === "system.info") { + result = { ok: true }; + } + queueMicrotask(() => { + const set = this._handlers.get("message"); + if (!set) return; + set.forEach((cb) => cb(JSON.stringify({ id: msg.id, result }))); + }); + } + close() {} + } + + return { default: FakeWebSocket }; +}); + +import truenasProxyHandler from "./proxy"; + +describe("widgets/truenas/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + validateWidgetData.mockReturnValue(true); + }); + + it("uses websocket calls for v2+ and returns JSON result", async () => { + getServiceWidget.mockResolvedValue({ + type: "truenas", + url: "http://tn", + version: 2, + key: "apikey", + }); + + const req = { query: { group: "g", service: "svc", endpoint: "stats", index: "0" } }; + const res = createMockRes(); + + await truenasProxyHandler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); +}); diff --git a/src/widgets/unifi/proxy.test.js b/src/widgets/unifi/proxy.test.js new file mode 100644 index 000000000..c02a1ca38 --- /dev/null +++ b/src/widgets/unifi/proxy.test.js @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { httpProxy, getServiceWidget, getPrivateWidgetOptions, cache, cookieJar, logger } = vi.hoisted(() => { + const store = new Map(); + return { + httpProxy: vi.fn(), + getServiceWidget: vi.fn(), + getPrivateWidgetOptions: 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(), + }, + cookieJar: { + addCookieToJar: vi.fn(), + setCookieHeader: vi.fn(), + }, + logger: { debug: vi.fn(), error: 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/config/widget-helpers", () => ({ + getPrivateWidgetOptions, +})); +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); +vi.mock("utils/proxy/cookie-jar", () => cookieJar); +vi.mock("widgets/widgets", () => ({ + default: { + unifi: { + api: "{url}{prefix}/api/{endpoint}", + }, + }, +})); + +import unifiProxyHandler from "./proxy"; + +describe("widgets/unifi/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache._reset(); + }); + + it("auto-detects prefix, logs in on 401, and retries the request", async () => { + getServiceWidget.mockResolvedValue({ + type: "unifi", + url: "http://unifi", + username: "u", + password: "p", + }); + + httpProxy + // autodetect call -> csrf header indicates udmp prefix + .mockResolvedValueOnce([200, "text/html", Buffer.from(""), { "x-csrf-token": "csrf" }]) + // initial api call -> unauthorized + .mockResolvedValueOnce([401, "application/json", Buffer.from("nope"), { "x-csrf-token": "csrf2" }]) + // login -> ok + .mockResolvedValueOnce([ + 200, + "application/json", + Buffer.from(JSON.stringify({ meta: { rc: "ok" } })), + { "set-cookie": ["sid=1"] }, + ]) + // retry api call -> ok + .mockResolvedValueOnce([200, "application/json", Buffer.from("data"), {}]); + + const req = { query: { group: "g", service: "svc", endpoint: "self", index: "0" } }; + const res = createMockRes(); + + await unifiProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(4); + expect(httpProxy.mock.calls[1][0].toString()).toContain("/proxy/network/api/self"); + expect(cookieJar.addCookieToJar).toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(Buffer.from("data")); + }); +});