mirror of
https://github.com/gethomepage/homepage.git
synced 2026-02-08 00:40:52 +08:00
Add core unit tests (config, highlights, proxy handlers)
This commit is contained in:
79
src/pages/api/services/proxy.test.js
Normal file
79
src/pages/api/services/proxy.test.js
Normal file
@@ -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" });
|
||||
});
|
||||
});
|
||||
59
src/utils/config/config.test.js
Normal file
59
src/utils/config/config.test.js
Normal file
@@ -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" } });
|
||||
});
|
||||
});
|
||||
50
src/utils/highlights.test.js
Normal file
50
src/utils/highlights.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
83
src/utils/proxy/handlers/credentialed.test.js
Normal file
83
src/utils/proxy/handlers/credentialed.test.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user