diff --git a/src/utils/proxy/cookie-jar.test.js b/src/utils/proxy/cookie-jar.test.js new file mode 100644 index 000000000..29c4bee96 --- /dev/null +++ b/src/utils/proxy/cookie-jar.test.js @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("utils/proxy/cookie-jar", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("adds cookies to the jar and sets Cookie header on subsequent requests", async () => { + const { addCookieToJar, setCookieHeader } = await import("./cookie-jar"); + + const url = new URL("http://example.test/path"); + addCookieToJar(url, { "set-cookie": ["a=b; Path=/"] }); + + const params = { headers: {} }; + setCookieHeader(url, params); + + expect(params.headers.Cookie).toContain("a=b"); + }); + + it("supports custom cookie header names via params.cookieHeader", async () => { + const { addCookieToJar, setCookieHeader } = await import("./cookie-jar"); + + const url = new URL("http://example2.test/path"); + addCookieToJar(url, { "set-cookie": ["sid=1; Path=/"] }); + + const params = { headers: {}, cookieHeader: "X-Auth-Token" }; + setCookieHeader(url, params); + + expect(params.headers["X-Auth-Token"]).toContain("sid=1"); + }); + + it("supports Headers instances passed as response headers", async () => { + const { addCookieToJar, setCookieHeader } = await import("./cookie-jar"); + + const url = new URL("http://example3.test/path"); + const headers = new Headers(); + headers.set("set-cookie", "c=d; Path=/"); + addCookieToJar(url, headers); + + const params = { headers: {} }; + setCookieHeader(url, params); + + expect(params.headers.Cookie).toContain("c=d"); + }); +}); diff --git a/src/utils/proxy/handlers/generic.test.js b/src/utils/proxy/handlers/generic.test.js new file mode 100644 index 000000000..3c9f84081 --- /dev/null +++ b/src/utils/proxy/handlers/generic.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, 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("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 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("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"); + }); +}); diff --git a/src/utils/proxy/handlers/jsonrpc.test.js b/src/utils/proxy/handlers/jsonrpc.test.js new file mode 100644 index 000000000..68263653f --- /dev/null +++ b/src/utils/proxy/handlers/jsonrpc.test.js @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { httpProxy, logger } = vi.hoisted(() => ({ + httpProxy: vi.fn(), + logger: { debug: vi.fn(), warn: vi.fn() }, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); +vi.mock("utils/proxy/http", () => ({ + httpProxy, +})); + +import { sendJsonRpcRequest } from "./jsonrpc"; + +describe("utils/proxy/handlers/jsonrpc", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends a JSON-RPC request and returns the response", async () => { + 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 () => { + 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"); + }); +}); diff --git a/src/utils/proxy/handlers/synology.test.js b/src/utils/proxy/handlers/synology.test.js new file mode 100644 index 000000000..1d4c8480b --- /dev/null +++ b/src/utils/proxy/handlers/synology.test.js @@ -0,0 +1,126 @@ +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("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("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." }); + }); +});