diff --git a/src/test-utils/create-mock-res.js b/src/test-utils/create-mock-res.js new file mode 100644 index 000000000..7713b8f2e --- /dev/null +++ b/src/test-utils/create-mock-res.js @@ -0,0 +1,36 @@ +import { vi } from "vitest"; + +export default function createMockRes() { + const res = { + statusCode: null, + body: null, + headers: {}, + }; + + res.status = vi.fn((code) => { + res.statusCode = code; + return res; + }); + + res.json = vi.fn((body) => { + res.body = body; + return res; + }); + + res.send = vi.fn((body) => { + res.body = body; + return res; + }); + + res.end = vi.fn((body) => { + res.body = body; + return res; + }); + + res.setHeader = vi.fn((key, value) => { + res.headers[key] = value; + return res; + }); + + return res; +} diff --git a/src/widgets/beszel/proxy.test.js b/src/widgets/beszel/proxy.test.js new file mode 100644 index 000000000..68d6509af --- /dev/null +++ b/src/widgets/beszel/proxy.test.js @@ -0,0 +1,117 @@ +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: { + beszel: { + api: "{url}/{endpoint}", + mappings: { + authv1: { endpoint: "api/auth" }, + authv2: { endpoint: "api/auth/v2" }, + }, + }, + }, +})); + +import beszelProxyHandler from "./proxy"; + +describe("widgets/beszel/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache._reset(); + }); + + it("logs in when token is missing and uses Bearer token for requests", async () => { + getServiceWidget.mockResolvedValue({ + type: "beszel", + url: "http://beszel", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ token: "t1" }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ items: [1] }))]); + + const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } }; + const res = createMockRes(); + + await beszelProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(2); + expect(httpProxy.mock.calls[0][0]).toBe("http://beszel/api/auth"); + expect(httpProxy.mock.calls[1][0].toString()).toBe("http://beszel/items"); + expect(httpProxy.mock.calls[1][1]).toEqual({ + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer t1", + }, + }); + expect(res.send).toHaveBeenCalledWith(Buffer.from(JSON.stringify({ items: [1] }))); + }); + + it("retries after receiving an empty list by clearing cache and logging in again", async () => { + cache.put("beszelProxyHandler__token.svc", "old"); + + getServiceWidget.mockResolvedValue({ + type: "beszel", + url: "http://beszel", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ items: [] }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ token: "new" }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ items: [1] }))]); + + const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } }; + const res = createMockRes(); + + await beszelProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer old"); + expect(httpProxy.mock.calls[1][0]).toBe("http://beszel/api/auth"); + expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("Bearer new"); + expect(res.send).toHaveBeenCalledWith(Buffer.from(JSON.stringify({ items: [1] }))); + }); +}); diff --git a/src/widgets/flood/proxy.test.js b/src/widgets/flood/proxy.test.js new file mode 100644 index 000000000..a3b9564bb --- /dev/null +++ b/src/widgets/flood/proxy.test.js @@ -0,0 +1,69 @@ +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 floodProxyHandler from "./proxy"; + +describe("widgets/flood/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("logs in and retries after a 401 response", async () => { + getServiceWidget.mockResolvedValue({ url: "http://flood" }); + httpProxy + .mockResolvedValueOnce([401, "application/json", Buffer.from("nope")]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("data")]); + + const req = { query: { group: "g", service: "svc", endpoint: "stats", index: "0" } }; + const res = createMockRes(); + + await floodProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(httpProxy.mock.calls[0][0].toString()).toBe("http://flood/api/stats"); + expect(httpProxy.mock.calls[1][0]).toBe("http://flood/api/auth/authenticate"); + expect(httpProxy.mock.calls[1][1].body).toBeNull(); + expect(httpProxy.mock.calls[2][0].toString()).toBe("http://flood/api/stats"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(Buffer.from("data")); + }); + + it("returns the login error status when authentication fails", async () => { + getServiceWidget.mockResolvedValue({ url: "http://flood", username: "u", password: "p" }); + httpProxy + .mockResolvedValueOnce([401, "application/json", Buffer.from("nope")]) + .mockResolvedValueOnce([500, "application/json", Buffer.from("bad")]); + + const req = { query: { group: "g", service: "svc", endpoint: "stats", index: "0" } }; + const res = createMockRes(); + + await floodProxyHandler(req, res); + + expect(httpProxy.mock.calls[1][1].body).toBe(JSON.stringify({ username: "u", password: "p" })); + expect(res.statusCode).toBe(500); + expect(res.end).toHaveBeenCalledWith(Buffer.from("bad")); + }); +}); diff --git a/src/widgets/freshrss/proxy.test.js b/src/widgets/freshrss/proxy.test.js new file mode 100644 index 000000000..7a3b0fa1d --- /dev/null +++ b/src/widgets/freshrss/proxy.test.js @@ -0,0 +1,112 @@ +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: { + freshrss: { + api: "{url}/{endpoint}", + }, + }, +})); + +import freshrssProxyHandler from "./proxy"; + +describe("widgets/freshrss/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache._reset(); + }); + + it("logs in, caches token, and returns subscription + unread counts", async () => { + getServiceWidget.mockResolvedValue({ + type: "freshrss", + url: "http://fresh", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([200, "text/plain", Buffer.from("SID=1\nAuth=token123\n")]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ subscriptions: [1, 2, 3] }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ max: 7 }))]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await freshrssProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(httpProxy.mock.calls[0][0].toString()).toBe("http://fresh/accounts/ClientLogin"); + expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe("GoogleLogin auth=token123"); + expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("GoogleLogin auth=token123"); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ subscriptions: 3, unread: 7 }); + }); + + it("retries API calls after a 401 by obtaining a new session token", async () => { + cache.put("freshrssProxyHandler__sessionToken.svc", "old"); + + getServiceWidget.mockResolvedValue({ + type: "freshrss", + url: "http://fresh", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([401, "application/json", Buffer.from("{}")]) + .mockResolvedValueOnce([200, "text/plain", Buffer.from("SID=1\nAuth=newtok\n")]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ subscriptions: [1] }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ max: 2 }))]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await freshrssProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(4); + const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("accounts/ClientLogin")); + expect(loginCalls).toHaveLength(1); + const listCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("subscription/list")); + expect(listCalls).toHaveLength(2); + expect(res.body).toEqual({ subscriptions: 1, unread: 2 }); + }); +}); diff --git a/src/widgets/jellyfin/proxy.test.js b/src/widgets/jellyfin/proxy.test.js new file mode 100644 index 000000000..c8267839d --- /dev/null +++ b/src/widgets/jellyfin/proxy.test.js @@ -0,0 +1,102 @@ +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(), + 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/validate-widget-data", () => ({ + default: validateWidgetData, +})); + +vi.mock("widgets/widgets", () => ({ + default: { + jellyfin: { + api: "{url}/{endpoint}", + }, + }, +})); + +import jellyfinProxyHandler from "./proxy"; + +describe("widgets/jellyfin/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + validateWidgetData.mockReturnValue(true); + }); + + it("adds MediaBrowser auth header and applies an optional mapping function", async () => { + getServiceWidget.mockResolvedValue({ + type: "jellyfin", + url: "http://jf", + key: "abc", + service_group: "mygroup", + service_name: "myservice", + }); + + httpProxy.mockResolvedValueOnce([200, "application/json", { items: [1] }]); + + const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "Users", index: "0" } }; + const res = createMockRes(); + + await jellyfinProxyHandler(req, res, () => ({ mapped: true })); + + expect(httpProxy).toHaveBeenCalledTimes(1); + expect(httpProxy.mock.calls[0][0].toString()).toBe("http://jf/Users"); + expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe( + 'MediaBrowser Token="abc", Client="Homepage", Device="Homepage", DeviceId="mygroup-myservice", Version="1.0.0"', + ); + expect(validateWidgetData).toHaveBeenCalledWith(expect.objectContaining({ type: "jellyfin" }), "Users", { + items: [1], + }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ mapped: true }); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/json"); + }); + + it("returns 500 when data validation fails", async () => { + validateWidgetData.mockReturnValue(false); + getServiceWidget.mockResolvedValue({ type: "jellyfin", url: "http://jf", key: "abc" }); + httpProxy.mockResolvedValueOnce([200, "application/json", { nope: true }]); + + const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "Users", index: "0" } }; + const res = createMockRes(); + + await jellyfinProxyHandler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body.error.message).toBe("Invalid data"); + }); + + it("ends the response for 204 responses", async () => { + getServiceWidget.mockResolvedValue({ type: "jellyfin", url: "http://jf", key: "abc" }); + httpProxy.mockResolvedValueOnce([204, "application/json", {}]); + + const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "Users", index: "0" } }; + const res = createMockRes(); + + await jellyfinProxyHandler(req, res); + + expect(res.statusCode).toBe(204); + expect(res.end).toHaveBeenCalled(); + }); +}); diff --git a/src/widgets/minecraft/proxy.test.js b/src/widgets/minecraft/proxy.test.js new file mode 100644 index 000000000..c1a56cd76 --- /dev/null +++ b/src/widgets/minecraft/proxy.test.js @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { mc, getServiceWidget, logger } = vi.hoisted(() => ({ + mc: { lookup: vi.fn() }, + getServiceWidget: vi.fn(), + logger: { error: vi.fn() }, +})); + +vi.mock("minecraftstatuspinger", () => ({ + default: mc, + ...mc, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +import minecraftProxyHandler from "./proxy"; + +describe("widgets/minecraft/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns online=true with version and player data when lookup succeeds", async () => { + getServiceWidget.mockResolvedValue({ url: "http://example.com:25565" }); + mc.lookup.mockResolvedValue({ + status: { + version: { name: "1.20" }, + players: { online: 1, max: 10 }, + }, + }); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await minecraftProxyHandler(req, res); + + expect(mc.lookup).toHaveBeenCalledWith({ host: "example.com", port: "25565" }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + version: "1.20", + online: true, + players: { online: 1, max: 10 }, + }); + }); + + it("returns online=false when lookup fails", async () => { + getServiceWidget.mockResolvedValue({ url: "http://example.com:25565" }); + mc.lookup.mockRejectedValue(new Error("nope")); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await minecraftProxyHandler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ version: undefined, online: false, players: undefined }); + }); +}); diff --git a/src/widgets/npm/proxy.test.js b/src/widgets/npm/proxy.test.js new file mode 100644 index 000000000..23c1b1ebd --- /dev/null +++ b/src/widgets/npm/proxy.test.js @@ -0,0 +1,114 @@ +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: { + npm: { + api: "{url}/{endpoint}", + }, + }, +})); + +import npmProxyHandler from "./proxy"; + +describe("widgets/npm/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache._reset(); + }); + + it("logs in when token is missing and uses Bearer token for requests", async () => { + getServiceWidget.mockResolvedValue({ + type: "npm", + url: "http://npm", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([ + 200, + "application/json", + Buffer.from(JSON.stringify({ token: "t1", expires: new Date(Date.now() + 60_000).toISOString() })), + ]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("data")]); + + const req = { query: { group: "g", service: "svc", endpoint: "api/v1/stats", index: "0" } }; + const res = createMockRes(); + + await npmProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(2); + expect(httpProxy.mock.calls[0][0]).toBe("http://npm/api/tokens"); + expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe("Bearer t1"); + expect(res.body).toEqual(Buffer.from("data")); + }); + + it("retries after a 403 response by clearing cache and logging in again", async () => { + cache.put("npmProxyHandler__token.svc", "old"); + + getServiceWidget.mockResolvedValue({ + type: "npm", + url: "http://npm", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([403, "application/json", Buffer.from("nope")]) + .mockResolvedValueOnce([ + 200, + "application/json", + Buffer.from(JSON.stringify({ token: "new", expires: new Date(Date.now() + 60_000).toISOString() })), + ]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]); + + const req = { query: { group: "g", service: "svc", endpoint: "api/v1/stats", index: "0" } }; + const res = createMockRes(); + + await npmProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(3); + expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer old"); + expect(httpProxy.mock.calls[1][0]).toBe("http://npm/api/tokens"); + expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("Bearer new"); + expect(res.body).toEqual(Buffer.from("ok")); + }); +}); diff --git a/src/widgets/pyload/proxy.test.js b/src/widgets/pyload/proxy.test.js new file mode 100644 index 000000000..d9d558fb7 --- /dev/null +++ b/src/widgets/pyload/proxy.test.js @@ -0,0 +1,105 @@ +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: { + error: vi.fn(), + info: 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: { + pyload: { + api: "{url}/api/{endpoint}", + }, + }, +})); + +import pyloadProxyHandler from "./proxy"; + +describe("widgets/pyload/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + cache._reset(); + }); + + it("uses Basic auth when credentials work and returns data", async () => { + getServiceWidget.mockResolvedValue({ + type: "pyload", + url: "http://pyload", + username: "u", + password: "p", + }); + + httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ ok: true })), {}]); + + const req = { query: { group: "g", service: "svc", endpoint: "status", index: "0" } }; + const res = createMockRes(); + + await pyloadProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(1); + expect(httpProxy.mock.calls[0][1].headers.Authorization).toMatch(/^Basic /); + expect(cache.put).toHaveBeenCalledWith("pyloadProxyHandler__isNg.svc", true); + expect(res.body).toEqual({ ok: true }); + }); + + it("retries after 403 by clearing session and logging in again", async () => { + getServiceWidget.mockResolvedValue({ + type: "pyload", + url: "http://pyload", + username: "u", + password: "", + }); + + httpProxy + // login -> sessionId + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify("sid1")), {}]) + // fetch -> unauthorized + .mockResolvedValueOnce([403, "application/json", Buffer.from(JSON.stringify({ error: "bad" })), {}]) + // relogin -> sessionId + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify("sid2")), {}]) + // retry fetch -> ok + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ ok: true })), {}]); + + const req = { query: { group: "g", service: "svc", endpoint: "status", index: "0" } }; + const res = createMockRes(); + + await pyloadProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(4); + expect(cache.del).toHaveBeenCalledWith("pyloadProxyHandler__sessionId.svc"); + expect(res.body).toEqual({ ok: true }); + }); +}); diff --git a/src/widgets/urbackup/proxy.test.js b/src/widgets/urbackup/proxy.test.js new file mode 100644 index 000000000..7ccaab47d --- /dev/null +++ b/src/widgets/urbackup/proxy.test.js @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { UrbackupServer, state, getServiceWidget } = vi.hoisted(() => { + const state = { instances: [] }; + + const UrbackupServer = vi.fn((opts) => { + const instance = { + opts, + getStatus: vi.fn(), + getUsage: vi.fn(), + }; + state.instances.push(instance); + return instance; + }); + + return { + UrbackupServer, + state, + getServiceWidget: vi.fn(), + }; +}); + +vi.mock("urbackup-server-api", () => ({ + UrbackupServer, +})); + +vi.mock("utils/config/service-helpers", () => ({ + default: getServiceWidget, +})); + +import urbackupProxyHandler from "./proxy"; + +describe("widgets/urbackup/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.instances.length = 0; + }); + + it("returns client statuses and maxDays without disk usage by default", async () => { + getServiceWidget.mockResolvedValue({ + url: "http://ur", + username: "u", + password: "p", + maxDays: 5, + }); + + UrbackupServer.mockImplementationOnce((opts) => { + const instance = { + opts, + getStatus: vi.fn().mockResolvedValue([{ id: 1 }]), + getUsage: vi.fn(), + }; + state.instances.push(instance); + return instance; + }); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await urbackupProxyHandler(req, res); + + expect(UrbackupServer).toHaveBeenCalledWith({ url: "http://ur", username: "u", password: "p" }); + expect(state.instances[0].getUsage).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ clientStatuses: [{ id: 1 }], diskUsage: false, maxDays: 5 }); + }); + + it("fetches disk usage when requested via fields", async () => { + getServiceWidget.mockResolvedValue({ + url: "http://ur", + username: "u", + password: "p", + maxDays: 1, + fields: ["totalUsed"], + }); + + UrbackupServer.mockImplementationOnce((opts) => { + const instance = { + opts, + getStatus: vi.fn().mockResolvedValue([{ id: 1 }]), + getUsage: vi.fn().mockResolvedValue({ totalUsed: 123 }), + }; + state.instances.push(instance); + return instance; + }); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await urbackupProxyHandler(req, res); + + expect(state.instances[0].getUsage).toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body.diskUsage).toEqual({ totalUsed: 123 }); + }); + + it("returns 500 on server errors", async () => { + getServiceWidget.mockResolvedValue({ url: "http://ur", username: "u", password: "p" }); + + UrbackupServer.mockImplementationOnce((opts) => { + const instance = { + opts, + getStatus: vi.fn().mockRejectedValue(new Error("nope")), + getUsage: vi.fn(), + }; + state.instances.push(instance); + return instance; + }); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await urbackupProxyHandler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: "Error communicating with UrBackup server" }); + }); +}); diff --git a/src/widgets/watchtower/proxy.test.js b/src/widgets/watchtower/proxy.test.js new file mode 100644 index 000000000..be59001a3 --- /dev/null +++ b/src/widgets/watchtower/proxy.test.js @@ -0,0 +1,64 @@ +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: { + watchtower: { + api: "{url}/{endpoint}", + }, + }, +})); + +import watchtowerProxyHandler from "./proxy"; + +describe("widgets/watchtower/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("parses watchtower metrics and returns a key/value object", async () => { + getServiceWidget.mockResolvedValue({ type: "watchtower", url: "http://watch", key: "k" }); + httpProxy.mockResolvedValueOnce([ + 200, + "text/plain", + Buffer.from("watchtower_running 1\nfoo 2\nwatchtower_status 3\n"), + ]); + + const req = { query: { group: "g", service: "svc", endpoint: "metrics", index: "0" } }; + const res = createMockRes(); + + await watchtowerProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(1); + expect(httpProxy.mock.calls[0][0].toString()).toBe("http://watch/metrics"); + expect(httpProxy.mock.calls[0][1]).toEqual({ + method: "GET", + headers: { Authorization: "Bearer k" }, + }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ watchtower_running: "1", watchtower_status: "3" }); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/plain"); + }); +}); diff --git a/src/widgets/xteve/proxy.test.js b/src/widgets/xteve/proxy.test.js new file mode 100644 index 000000000..3c6c10c8a --- /dev/null +++ b/src/widgets/xteve/proxy.test.js @@ -0,0 +1,101 @@ +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(), + }, +})); + +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: { + xteve: { + api: "{url}/{endpoint}", + }, + }, +})); + +import xteveProxyHandler from "./proxy"; + +describe("widgets/xteve/proxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("logs in when credentials are provided and includes token in subsequent status request", async () => { + getServiceWidget.mockResolvedValue({ + type: "xteve", + url: "http://xteve", + username: "u", + password: "p", + }); + + httpProxy + .mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ status: true, token: "tok" }))]) + .mockResolvedValueOnce([200, "application/json", Buffer.from("status-data")]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await xteveProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(2); + expect(httpProxy.mock.calls[0][0]).toBe("http://xteve/api/"); + expect(JSON.parse(httpProxy.mock.calls[0][1].body)).toEqual({ + cmd: "login", + username: "u", + password: "p", + }); + expect(JSON.parse(httpProxy.mock.calls[1][1].body)).toEqual({ cmd: "status", token: "tok" }); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(Buffer.from("status-data")); + }); + + it("returns 401 when authentication fails", async () => { + getServiceWidget.mockResolvedValue({ + type: "xteve", + url: "http://xteve", + username: "u", + password: "p", + }); + + httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ status: false }))]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await xteveProxyHandler(req, res); + + expect(res.statusCode).toBe(401); + expect(res.body.error.message).toBe("Authentication failed"); + }); + + it("skips login when credentials are not provided", async () => { + getServiceWidget.mockResolvedValue({ type: "xteve", url: "http://xteve" }); + httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("status-data")]); + + const req = { query: { group: "g", service: "svc", index: "0" } }; + const res = createMockRes(); + + await xteveProxyHandler(req, res); + + expect(httpProxy).toHaveBeenCalledTimes(1); + expect(JSON.parse(httpProxy.mock.calls[0][1].body)).toEqual({ cmd: "status" }); + expect(res.statusCode).toBe(200); + }); +});