diff --git a/src/pages/api/services/proxy.test.js b/src/pages/api/services/proxy.test.js new file mode 100644 index 000000000..b269c970a --- /dev/null +++ b/src/pages/api/services/proxy.test.js @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +const { getServiceWidget } = vi.hoisted(() => ({ getServiceWidget: vi.fn() })); + +vi.mock("utils/logger", () => ({ + default: () => ({ debug: vi.fn(), error: vi.fn() }), +})); + +vi.mock("utils/config/service-helpers", () => ({ default: getServiceWidget })); + +const handlerFn = vi.hoisted(() => ({ handler: vi.fn() })); +vi.mock("utils/proxy/handlers/generic", () => ({ default: handlerFn.handler })); + +// Calendar proxy is only used for an exception; keep it stubbed. +vi.mock("widgets/calendar/proxy", () => ({ default: vi.fn() })); + +// Provide a minimal widget registry for mapping tests. +vi.mock("widgets/widgets", () => ({ + default: { + linkwarden: { + api: "{url}/api/v1/{endpoint}", + mappings: { + collections: { endpoint: "collections" }, + }, + }, + }, +})); + +import servicesProxy from "./proxy"; + +function createMockRes() { + const res = { + statusCode: undefined, + body: undefined, + status: (code) => { + res.statusCode = code; + return res; + }, + json: (data) => { + res.body = data; + return res; + }, + send: (data) => { + res.body = data; + return res; + }, + end: () => res, + setHeader: vi.fn(), + }; + return res; +} + +describe("pages/api/services/proxy", () => { + it("maps opaque endpoints using widget.mappings and calls the handler", async () => { + getServiceWidget.mockResolvedValue({ type: "linkwarden" }); + handlerFn.handler.mockImplementation(async (req, res) => res.status(200).json({ endpoint: req.query.endpoint })); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "collections" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(handlerFn.handler).toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ endpoint: "collections" }); + }); + + it("returns 403 for unsupported endpoint mapping", async () => { + getServiceWidget.mockResolvedValue({ type: "linkwarden" }); + + const req = { method: "GET", query: { group: "g", service: "s", index: "0", endpoint: "nope" } }; + const res = createMockRes(); + + await servicesProxy(req, res); + + expect(res.statusCode).toBe(403); + expect(res.body).toEqual({ error: "Unsupported service endpoint" }); + }); +}); diff --git a/src/utils/config/config.test.js b/src/utils/config/config.test.js new file mode 100644 index 000000000..e2936b68c --- /dev/null +++ b/src/utils/config/config.test.js @@ -0,0 +1,59 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import cache from "memory-cache"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("utils/config/config", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + cache.del("homepageEnvironmentVariables"); + }); + + afterEach(() => { + process.env = originalEnv; + cache.del("homepageEnvironmentVariables"); + }); + + it("substituteEnvironmentVars replaces HOMEPAGE_VAR_* placeholders", async () => { + process.env.HOMEPAGE_VAR_FOO = "bar"; + + const mod = await import("./config"); + expect(mod.substituteEnvironmentVars("x {{HOMEPAGE_VAR_FOO}} y")).toBe("x bar y"); + }); + + it("substituteEnvironmentVars replaces HOMEPAGE_FILE_* placeholders with file contents", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "homepage-config-test-")); + const secretPath = path.join(dir, "secret.txt"); + writeFileSync(secretPath, "secret", "utf8"); + + process.env.HOMEPAGE_FILE_SECRET = secretPath; + + const mod = await import("./config"); + expect(mod.substituteEnvironmentVars("token={{HOMEPAGE_FILE_SECRET}}")).toBe("token=secret"); + }); + + it("getSettings reads from HOMEPAGE_CONFIG_DIR and converts layout list to an object", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "homepage-settings-test-")); + process.env.HOMEPAGE_CONFIG_DIR = dir; + process.env.HOMEPAGE_VAR_TITLE = "MyTitle"; + + // Create a minimal settings.yaml; checkAndCopyConfig will see it exists and won't copy skeleton. + writeFileSync( + path.join(dir, "settings.yaml"), + ['title: "{{HOMEPAGE_VAR_TITLE}}"', "layout:", " - GroupA:", " style: row"].join("\n"), + "utf8", + ); + + vi.resetModules(); // ensure CONF_DIR is computed from updated env + const mod = await import("./config"); + + const settings = mod.getSettings(); + expect(settings.title).toBe("MyTitle"); + expect(settings.layout).toEqual({ GroupA: { style: "row" } }); + }); +}); diff --git a/src/utils/highlights.test.js b/src/utils/highlights.test.js new file mode 100644 index 000000000..a7bfcf3af --- /dev/null +++ b/src/utils/highlights.test.js @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { buildHighlightConfig, evaluateHighlight, getHighlightClass } from "./highlights"; + +describe("utils/highlights", () => { + it("buildHighlightConfig merges levels and namespaces unqualified field keys", () => { + const cfg = buildHighlightConfig( + { levels: { warn: "global-warn" } }, + { + levels: { warn: "widget-warn", custom: "widget-custom" }, + cpu: { numeric: { when: "gt", value: 80, level: "warn" } }, + }, + "resources", + ); + + expect(cfg).not.toBeNull(); + expect(cfg.levels.warn).toBe("widget-warn"); + expect(cfg.levels.custom).toBe("widget-custom"); + + // Field keys get normalized + namespaced. + expect(cfg.fields.cpu).toBeTruthy(); + expect(cfg.fields["resources.cpu"]).toBeTruthy(); + }); + + it("evaluateHighlight returns matching numeric rule with valueOnly metadata", () => { + const cfg = buildHighlightConfig( + null, + { + // valueOnly should propagate through the result so Block can apply styling. + cpu: { valueOnly: true, numeric: { when: "gte", value: 90, level: "danger" } }, + }, + "resources", + ); + + const hit = evaluateHighlight("resources.cpu", " 90 ", cfg); + expect(hit).toMatchObject({ level: "danger", source: "numeric", valueOnly: true }); + }); + + it("evaluateHighlight supports string rules (case-insensitive includes)", () => { + const cfg = buildHighlightConfig(null, { status: { string: { when: "includes", value: "down", level: "warn" } } }); + + const hit = evaluateHighlight("status", "Service DOWN", cfg); + expect(hit).toMatchObject({ level: "warn", source: "string" }); + }); + + it("getHighlightClass returns configured class for a level", () => { + const cfg = buildHighlightConfig({ levels: { danger: "danger-class" } }, {}, "x"); + expect(getHighlightClass("danger", cfg)).toBe("danger-class"); + }); +}); diff --git a/src/utils/proxy/handlers/credentialed.test.js b/src/utils/proxy/handlers/credentialed.test.js new file mode 100644 index 000000000..0f3ef854f --- /dev/null +++ b/src/utils/proxy/handlers/credentialed.test.js @@ -0,0 +1,83 @@ +import { 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() })); + +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 })); + +// Keep the widget registry minimal so the test doesn't import the whole widget graph. +vi.mock("widgets/widgets", () => ({ + default: { + linkwarden: { api: "{url}/api/v1/{endpoint}" }, + nextcloud: { api: "{url}/ocs/v2.php/apps/serverinfo/api/v1/{endpoint}" }, + }, +})); + +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", () => { + 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(); + }); +});