Chore: homepage tests (#6278)

This commit is contained in:
shamoon
2026-02-04 19:58:39 -08:00
committed by GitHub
parent 7d019185a3
commit 872a3600aa
558 changed files with 32606 additions and 84 deletions

View File

@@ -111,7 +111,7 @@ function ensureParentGroupExists(sortedGroups, configuredGroups, group, definedL
const parentGroupName = group.parent;
const parentGroup = findGroupByName(configuredGroups, parentGroupName);
if (parentGroup && parentGroup.parent) {
ensureParentGroupExists(sortedGroups, configuredGroups, parentGroup);
ensureParentGroupExists(sortedGroups, configuredGroups, parentGroup, definedLayouts);
} else {
const parentGroupIndex = definedLayouts.findIndex((layout) => layout === parentGroupName);
if (parentGroupIndex > -1) {

View File

@@ -0,0 +1,265 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fs, yaml, config, widgetHelpers, serviceHelpers } = vi.hoisted(() => ({
fs: {
readFile: vi.fn(),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
getSettings: vi.fn(),
substituteEnvironmentVars: vi.fn((s) => s),
default: vi.fn(),
},
widgetHelpers: {
widgetsFromConfig: vi.fn(),
cleanWidgetGroups: vi.fn(),
},
serviceHelpers: {
servicesFromDocker: vi.fn(),
servicesFromKubernetes: vi.fn(),
servicesFromConfig: vi.fn(),
cleanServiceGroups: vi.fn((g) => g),
findGroupByName: vi.fn(),
},
}));
vi.mock("fs", () => ({
promises: fs,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => config);
vi.mock("utils/config/widget-helpers", () => widgetHelpers);
vi.mock("utils/config/service-helpers", () => serviceHelpers);
import { bookmarksResponse, servicesResponse, widgetsResponse } from "./api-response";
describe("utils/config/api-response", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("bookmarksResponse returns [] when bookmarks are missing", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce(null);
const res = await bookmarksResponse();
expect(res).toEqual([]);
expect(config.getSettings).not.toHaveBeenCalled();
});
it("bookmarksResponse falls back when settings cannot be loaded", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fs.readFile.mockResolvedValueOnce("ignored");
config.getSettings.mockRejectedValueOnce(new Error("bad settings"));
yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: "a" }] }] }, { B: [{ LinkB: [{ href: "b" }] }] }]);
const res = await bookmarksResponse();
expect(res.map((g) => g.name)).toEqual(["A", "B"]);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it("bookmarksResponse sorts groups based on settings layout", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
config.getSettings.mockResolvedValueOnce({ layout: { B: {}, A: {} } });
yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: "a" }] }] }, { B: [{ LinkB: [{ href: "b" }] }] }]);
const res = await bookmarksResponse();
expect(res.map((g) => g.name)).toEqual(["B", "A"]);
});
it("bookmarksResponse appends groups not present in the layout", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
config.getSettings.mockResolvedValueOnce({ layout: { A: {} } });
yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: "a" }] }] }, { C: [{ LinkC: [{ href: "c" }] }] }]);
const res = await bookmarksResponse();
expect(res.map((g) => g.name)).toEqual(["A", "C"]);
});
it("widgetsResponse returns sanitized configured widgets", async () => {
widgetHelpers.widgetsFromConfig.mockResolvedValueOnce([{ type: "search", options: { url: "x" } }]);
widgetHelpers.cleanWidgetGroups.mockResolvedValueOnce([{ type: "search", options: { index: 0 } }]);
expect(await widgetsResponse()).toEqual([{ type: "search", options: { index: 0 } }]);
});
it("widgetsResponse returns [] when widgets cannot be loaded", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
widgetHelpers.widgetsFromConfig.mockRejectedValueOnce(new Error("bad widgets"));
expect(await widgetsResponse()).toEqual([]);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it("servicesResponse merges groups and sorts services by weight then name", async () => {
// Minimal stubs for findGroupByName used within servicesResponse.
serviceHelpers.findGroupByName.mockImplementation((groups, name) => groups.find((g) => g.name === name) ?? null);
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([
{
name: "GroupA",
services: [
{ name: "b", weight: 200 },
{ name: "a", weight: 200 },
],
groups: [],
},
]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([
{ name: "GroupA", services: [{ name: "c", weight: 100 }], groups: [] },
]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([
{ name: "GroupA", services: [{ name: "d", weight: 50 }], groups: [] },
{ name: "Empty", services: [], groups: [] },
]);
config.getSettings.mockResolvedValueOnce({ layout: { GroupA: {}, GroupB: {} } });
const groups = await servicesResponse();
expect(groups.map((g) => g.name)).toEqual(["GroupA"]);
expect(groups[0].services.map((s) => s.name)).toEqual(["d", "c", "a", "b"]);
});
it("servicesResponse logs when no docker services are discovered", async () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
serviceHelpers.findGroupByName.mockImplementation((groups, name) => groups.find((g) => g.name === name) ?? null);
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([]);
config.getSettings.mockResolvedValueOnce({});
const groups = await servicesResponse();
expect(groups).toEqual([]);
expect(debugSpy).toHaveBeenCalledWith("No containers were found with homepage labels.");
debugSpy.mockRestore();
});
it("servicesResponse tolerates discovery/load failures and returns [] when nothing can be loaded", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
serviceHelpers.servicesFromDocker.mockRejectedValueOnce(new Error("docker bad"));
serviceHelpers.servicesFromKubernetes.mockRejectedValueOnce(new Error("kube bad"));
serviceHelpers.servicesFromConfig.mockRejectedValueOnce(new Error("config bad"));
config.getSettings.mockRejectedValueOnce(new Error("settings bad"));
const groups = await servicesResponse();
expect(groups).toEqual([]);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it("servicesResponse supports multi-level nested layout groups and ensures the top-level parent exists", async () => {
serviceHelpers.findGroupByName.mockImplementation(function find(groups, name, parent) {
for (const group of groups ?? []) {
if (group.name === name) {
if (parent) group.parent = parent;
return group;
}
const found = find(group.groups, name, group.name);
if (found) return found;
}
return null;
});
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([
{ name: "Child", services: [{ name: "svc", weight: 1 }], groups: [] },
]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([{ name: "Root", services: [], groups: [] }]);
config.getSettings.mockResolvedValueOnce({ layout: { Root: { Top: { Child: {} } } } });
const groups = await servicesResponse();
expect(groups.map((g) => g.name)).toEqual(["Root"]);
expect(groups[0].groups[0].name).toBe("Top");
expect(groups[0].groups[0].groups[0].name).toBe("Child");
expect(groups[0].groups[0].groups[0].services).toEqual([{ name: "svc", weight: 1 }]);
});
it("servicesResponse merges discovered nested groups into their configured parent layout group", async () => {
serviceHelpers.findGroupByName.mockImplementation(function find(groups, name, parent) {
for (const group of groups ?? []) {
if (group.name === name) {
if (parent) group.parent = parent;
return group;
}
const found = find(group.groups, name, group.name);
if (found) return found;
}
return null;
});
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([
{
name: "Child",
services: [
{ name: "svcB", weight: 50 },
{ name: "svcA", weight: 10 },
],
groups: [],
},
]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([
{
name: "Top",
services: [],
groups: [{ name: "Child", services: [], groups: [] }],
},
]);
config.getSettings.mockResolvedValueOnce({ layout: { Top: { Child: {} } } });
const groups = await servicesResponse();
expect(groups.map((g) => g.name)).toEqual(["Top"]);
expect(groups[0].groups).toHaveLength(1);
expect(groups[0].groups[0].name).toBe("Child");
expect(groups[0].groups[0].services.map((s) => s.name)).toEqual(["svcA", "svcB"]);
});
it("servicesResponse merges nested discovered groups into their configured parent when no layout is defined", async () => {
serviceHelpers.findGroupByName.mockImplementation(function find(groups, name, parent) {
for (const group of groups ?? []) {
if (group.name === name) {
if (parent) group.parent = parent;
return group;
}
const found = find(group.groups, name, group.name);
if (found) return found;
}
return null;
});
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([
{ name: "Child", services: [{ name: "svc", weight: 1 }], groups: [] },
]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([
{ name: "Top", services: [], groups: [{ name: "Child", services: [], groups: [] }] },
]);
config.getSettings.mockResolvedValueOnce({});
const groups = await servicesResponse();
expect(groups.map((g) => g.name)).toEqual(["Top"]);
expect(groups[0].groups[0].name).toBe("Child");
expect(groups[0].groups[0].services).toEqual([{ name: "svc", weight: 1 }]);
});
});

View File

@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fs, yaml } = vi.hoisted(() => ({
fs: {
copyFileSync: vi.fn(),
existsSync: vi.fn(),
mkdirSync: vi.fn(),
readFileSync: vi.fn(),
},
yaml: {
load: vi.fn(),
},
}));
vi.mock("fs", () => fs);
vi.mock("js-yaml", () => ({ default: yaml, ...yaml }));
describe("utils/config/config checkAndCopyConfig", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
process.env = { ...originalEnv, HOMEPAGE_CONFIG_DIR: "/conf" };
});
it("returns false when it cannot create the config directory", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
fs.existsSync.mockReturnValueOnce(false);
fs.mkdirSync.mockImplementationOnce(() => {
throw new Error("no perms");
});
const mod = await import("./config");
expect(mod.default("services.yaml")).toBe(false);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it("copies the skeleton file when the config file does not exist", async () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
// dir exists
fs.existsSync.mockReturnValueOnce(true);
// config file missing
fs.existsSync.mockReturnValueOnce(false);
const mod = await import("./config");
expect(mod.default("services.yaml")).toBe(true);
expect(fs.copyFileSync).toHaveBeenCalled();
expect(infoSpy).toHaveBeenCalled();
infoSpy.mockRestore();
});
it("exits the process when copying the skeleton fails", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("exit");
});
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fs.existsSync.mockReturnValueOnce(true);
fs.existsSync.mockReturnValueOnce(false);
fs.copyFileSync.mockImplementationOnce(() => {
throw new Error("copy failed");
});
const mod = await import("./config");
expect(() => mod.default("services.yaml")).toThrow("exit");
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errSpy).toHaveBeenCalled();
exitSpy.mockRestore();
errSpy.mockRestore();
});
it("returns a parse error with config name when YAML is invalid", async () => {
fs.existsSync.mockReturnValueOnce(true);
fs.existsSync.mockReturnValueOnce(true);
fs.readFileSync.mockReturnValueOnce("bad");
yaml.load.mockImplementationOnce(() => {
throw Object.assign(new Error("yaml bad"), { name: "YAMLException" });
});
const mod = await import("./config");
const result = mod.default("services.yaml");
expect(result).toEqual(expect.objectContaining({ name: "YAMLException", config: "services.yaml" }));
});
});

View 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" } });
});
});

View File

@@ -5,6 +5,14 @@ import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
export function getDefaultDockerArgs(platform = process.platform) {
if (platform !== "win32" && platform !== "darwin") {
return { socketPath: "/var/run/docker.sock" };
}
return { host: "127.0.0.1" };
}
export default function getDockerArguments(server) {
checkAndCopyConfig("docker.yaml");
@@ -14,11 +22,7 @@ export default function getDockerArguments(server) {
const servers = yaml.load(configData);
if (!server) {
if (process.platform !== "win32" && process.platform !== "darwin") {
return { socketPath: "/var/run/docker.sock" };
}
return { host: "127.0.0.1" };
return getDefaultDockerArgs();
}
if (servers[server]) {

View File

@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
const { fs, yaml, config, checkAndCopyConfig } = vi.hoisted(() => ({
fs: {
readFileSync: vi.fn((filePath, encoding) => {
if (String(filePath).endsWith("/docker.yaml") && encoding === "utf8") return "docker-yaml";
return Buffer.from(String(filePath));
}),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
substituteEnvironmentVars: vi.fn((s) => s),
},
checkAndCopyConfig: vi.fn(),
}));
vi.mock("fs", () => ({
readFileSync: fs.readFileSync,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
...config,
}));
import getDockerArguments, { getDefaultDockerArgs } from "./docker";
describe("utils/config/docker", () => {
it("getDefaultDockerArgs returns a socketPath on linux and host on darwin", () => {
expect(getDefaultDockerArgs("linux")).toEqual({ socketPath: "/var/run/docker.sock" });
expect(getDefaultDockerArgs("darwin")).toEqual({ host: "127.0.0.1" });
});
it("returns default args when no server is given", () => {
yaml.load.mockReturnValueOnce({});
const args = getDockerArguments();
expect(checkAndCopyConfig).toHaveBeenCalledWith("docker.yaml");
// if running on linux, should return socketPath
if (process.platform !== "win32" && process.platform !== "darwin") {
expect(args).toEqual({ socketPath: "/var/run/docker.sock" });
} else {
// otherwise, should return host
expect(args).toEqual(expect.objectContaining({ host: expect.any(String) }));
}
});
it("returns socket config when server has a socket", () => {
yaml.load.mockReturnValueOnce({
"docker-local": { socket: "/tmp/docker.sock", swarm: true },
});
const args = getDockerArguments("docker-local");
expect(args).toEqual({ conn: { socketPath: "/tmp/docker.sock" }, swarm: true });
});
it("returns host/port/tls/protocol/headers config when provided", () => {
yaml.load.mockReturnValueOnce({
remote: {
host: "10.0.0.1",
port: 2376,
swarm: false,
protocol: "http",
headers: { "X-Test": "1" },
tls: { caFile: "ca.pem", certFile: "cert.pem", keyFile: "key.pem" },
},
});
const args = getDockerArguments("remote");
expect(args).toEqual(
expect.objectContaining({
swarm: false,
conn: expect.objectContaining({
host: "10.0.0.1",
port: 2376,
protocol: "http",
headers: { "X-Test": "1" },
ca: expect.any(Buffer),
cert: expect.any(Buffer),
key: expect.any(Buffer),
}),
}),
);
});
it("returns null when server is not configured", () => {
yaml.load.mockReturnValueOnce({ other: { host: "x" } });
expect(getDockerArguments("missing")).toBeNull();
});
it("returns the raw server config when it has no host/socket overrides", () => {
yaml.load.mockReturnValueOnce({
raw: { swarm: true, something: "else" },
});
expect(getDockerArguments("raw")).toEqual({ swarm: true, something: "else" });
});
});

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fs, yaml, config, checkAndCopyConfig, kube, apiExt } = vi.hoisted(() => {
const apiExt = {
readCustomResourceDefinitionStatus: vi.fn(),
};
const kube = {
loadFromCluster: vi.fn(),
loadFromDefault: vi.fn(),
makeApiClient: vi.fn(() => apiExt),
};
return {
fs: {
readFileSync: vi.fn(() => "kube-yaml"),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
substituteEnvironmentVars: vi.fn((s) => s),
},
checkAndCopyConfig: vi.fn(),
kube,
apiExt,
};
});
vi.mock("fs", () => ({
readFileSync: fs.readFileSync,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
...config,
}));
vi.mock("@kubernetes/client-node", () => ({
ApiextensionsV1Api: class ApiextensionsV1Api {},
KubeConfig: class KubeConfig {
loadFromCluster() {
return kube.loadFromCluster();
}
loadFromDefault() {
return kube.loadFromDefault();
}
makeApiClient() {
return kube.makeApiClient();
}
},
}));
import { checkCRD, getKubeConfig, getKubernetes } from "./kubernetes";
describe("utils/config/kubernetes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("getKubernetes loads and parses kubernetes.yaml", () => {
yaml.load.mockReturnValueOnce({ mode: "disabled" });
expect(getKubernetes()).toEqual({ mode: "disabled" });
expect(checkAndCopyConfig).toHaveBeenCalledWith("kubernetes.yaml");
});
it("getKubeConfig returns null when disabled", () => {
yaml.load.mockReturnValueOnce({ mode: "disabled" });
expect(getKubeConfig()).toBeNull();
});
it("getKubeConfig loads from cluster/default based on mode", () => {
yaml.load.mockReturnValueOnce({ mode: "cluster" });
const kc1 = getKubeConfig();
expect(kube.loadFromCluster).toHaveBeenCalled();
expect(kc1).not.toBeNull();
yaml.load.mockReturnValueOnce({ mode: "default" });
const kc2 = getKubeConfig();
expect(kube.loadFromDefault).toHaveBeenCalled();
expect(kc2).not.toBeNull();
});
it("checkCRD returns true when the CRD exists", async () => {
apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });
const logger = { error: vi.fn() };
await expect(checkCRD("x.example", kube, logger)).resolves.toBe(true);
});
it("checkCRD returns false and logs on 403", async () => {
apiExt.readCustomResourceDefinitionStatus.mockRejectedValueOnce({
statusCode: 403,
body: { message: "nope" },
});
const logger = { error: vi.fn() };
await expect(checkCRD("x.example", kube, logger)).resolves.toBe(false);
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
const { fs, yaml, config, checkAndCopyConfig } = vi.hoisted(() => ({
fs: {
readFileSync: vi.fn(() => "proxmox-yaml"),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
substituteEnvironmentVars: vi.fn((s) => s),
},
checkAndCopyConfig: vi.fn(),
}));
vi.mock("fs", () => ({
readFileSync: fs.readFileSync,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
...config,
}));
import { getProxmoxConfig } from "./proxmox";
describe("utils/config/proxmox", () => {
it("loads and parses proxmox.yaml", () => {
yaml.load.mockReturnValueOnce({ pve: { url: "http://pve" } });
expect(getProxmoxConfig()).toEqual({ pve: { url: "http://pve" } });
expect(checkAndCopyConfig).toHaveBeenCalledWith("proxmox.yaml");
expect(fs.readFileSync).toHaveBeenCalledWith("/conf/proxmox.yaml", "utf8");
});
});

View File

@@ -86,7 +86,7 @@ export async function servicesFromDocker() {
// bad docker connections can result in a <Buffer ...> object?
// in any case, this ensures the result is the expected array
if (!Array.isArray(containers)) {
return [];
return { server: serverName, services: [] };
}
const discovered = containers.map((container) => {
@@ -188,6 +188,7 @@ export async function servicesFromKubernetes() {
const resources = [...ingressList, ...traefikIngressList, ...httpRouteList];
/* c8 ignore next 3 -- resources is always an array once the spreads succeed */
if (!resources) {
return [];
}

View File

@@ -0,0 +1,587 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi } = vi.hoisted(() => {
const state = {
servicesYaml: null,
dockerYaml: null,
dockerContainers: [],
dockerContainersByServer: {},
dockerServicesByServer: {},
kubeConfig: null,
kubeServices: [],
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
const fs = {
readFile: vi.fn(async (filePath) => {
if (String(filePath).endsWith("/services.yaml")) return "services";
if (String(filePath).endsWith("/docker.yaml")) return "docker";
return "";
}),
};
const yaml = {
load: vi.fn((contents) => {
if (contents === "services") return state.servicesYaml;
if (contents === "docker") return state.dockerYaml;
return null;
}),
};
const config = {
CONF_DIR: "/conf",
getSettings: vi.fn(() => ({ instanceName: undefined })),
substituteEnvironmentVars: vi.fn((s) => s),
default: vi.fn(),
};
const Docker = vi.fn((conn) => ({
listContainers: vi.fn(async () => state.dockerContainersByServer[conn?.serverName] ?? state.dockerContainers),
listServices: vi.fn(async () => state.dockerServicesByServer[conn?.serverName] ?? state.dockerContainers),
}));
const dockerCfg = {
default: vi.fn((serverName) => ({ conn: { serverName } })),
};
const kubeCfg = {
getKubeConfig: vi.fn(() => state.kubeConfig),
};
const kubeApi = {
listIngress: vi.fn(async () => []),
listTraefikIngress: vi.fn(async () => []),
listHttpRoute: vi.fn(async () => []),
isDiscoverable: vi.fn(() => true),
constructedServiceFromResource: vi.fn(async () => state.kubeServices.shift()),
};
return { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi };
});
vi.mock("fs", () => ({
promises: fs,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => config);
vi.mock("dockerode", () => ({ default: Docker }));
vi.mock("utils/config/docker", () => dockerCfg);
vi.mock("utils/config/kubernetes", () => kubeCfg);
vi.mock("utils/kubernetes/export", () => ({ default: kubeApi }));
vi.mock("utils/logger", () => ({
// Keep a stable logger instance so tests don't depend on module re-imports.
default: vi.fn(() => state.logger),
}));
describe("utils/config/service-helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
state.servicesYaml = null;
state.dockerYaml = null;
state.dockerContainers = [];
state.dockerContainersByServer = {};
state.dockerServicesByServer = {};
state.kubeConfig = null;
state.kubeServices = [];
config.getSettings.mockReturnValue({ instanceName: undefined });
});
it("servicesFromConfig returns [] when services.yaml is empty", async () => {
state.servicesYaml = null;
const mod = await import("./service-helpers");
expect(await mod.servicesFromConfig()).toEqual([]);
});
it("servicesFromDocker returns [] when docker.yaml is empty", async () => {
state.dockerYaml = null;
const mod = await import("./service-helpers");
expect(await mod.servicesFromDocker()).toEqual([]);
});
it("servicesFromDocker tolerates non-array container responses from Docker", async () => {
state.dockerYaml = { "docker-local": {} };
state.dockerContainersByServer["docker-local"] = Buffer.from("bad docker response");
const mod = await import("./service-helpers");
const discovered = await mod.servicesFromDocker();
expect(discovered).toEqual([]);
});
it("servicesFromConfig parses nested groups, assigns default weights, and skips invalid entries", async () => {
state.servicesYaml = [
{
Main: [
{
Child: [{ SvcA: { icon: "a" } }, { SvcB: { icon: "b", weight: 5 } }],
},
{ SvcRoot: { icon: "r" } },
{ BadSvc: null },
],
},
];
const mod = await import("./service-helpers");
const groups = await mod.servicesFromConfig();
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe("Main");
expect(groups[0].type).toBe("group");
// Root services live on the group; child groups are nested.
expect(groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([
{ name: "SvcRoot", weight: 100 },
]);
expect(groups[0].groups).toHaveLength(1);
expect(groups[0].groups[0].name).toBe("Child");
expect(groups[0].groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([
{ name: "SvcA", weight: 100 },
{ name: "SvcB", weight: 5 },
]);
expect(state.logger.warn).toHaveBeenCalled();
});
it("cleanServiceGroups normalizes weights, moves widget->widgets, and parses per-widget settings", async () => {
const mod = await import("./service-helpers");
const { cleanServiceGroups } = mod;
const rawGroups = [
{
name: "Group",
services: [
{
name: "svc",
showStats: "true",
weight: "not-a-number",
widgets: [
// Invalid fields/highlight should be dropped with a log message.
{ type: "iframe", fields: "{bad}", highlight: "{bad}", src: "https://example.com" },
// Type-specific boolean parsing.
{ type: "portainer", kubernetes: "true" },
{ type: "deluge", enableLeechProgress: "true", enableLeechSize: "false" },
],
// `widget` is appended after the `widgets` array.
widget: {
type: "glances",
metric: "cpu",
chart: false,
version: "3",
refreshInterval: 1500,
pointsLimit: 10,
diskUnits: "gb",
fields: '["cpu"]',
highlight: '{"level":"warning"}',
hideErrors: true,
},
},
{
name: "svc2",
weight: {},
widget: { type: "openwrt", interfaceName: "eth0" },
},
{
name: "svc3",
weight: "7",
widget: { type: "frigate", enableRecentEvents: true },
},
],
groups: [],
},
];
const cleaned = cleanServiceGroups(rawGroups);
expect(cleaned).toHaveLength(1);
expect(cleaned[0].type).toBe("group");
expect(cleaned[0].services).toHaveLength(3);
const svc = cleaned[0].services[0];
expect(svc.showStats).toBe(true);
expect(svc.weight).toBe(0);
expect(svc.widgets).toHaveLength(4);
// The last widget is the appended `widget` entry; it should carry service metadata.
const glancesWidget = svc.widgets[3];
expect(glancesWidget.type).toBe("glances");
expect(glancesWidget.service_group).toBe("Group");
expect(glancesWidget.service_name).toBe("svc");
expect(glancesWidget.index).toBe(3);
expect(glancesWidget.hide_errors).toBe(true);
expect(glancesWidget.fields).toEqual(["cpu"]);
expect(glancesWidget.highlight).toEqual({ level: "warning" });
expect(glancesWidget.chart).toBe(false);
expect(glancesWidget.version).toBe(3);
// Type-specific parsing for other widgets.
expect(svc.widgets[1].kubernetes).toBe(true);
expect(svc.widgets[2].enableLeechProgress).toBe(true);
expect(svc.widgets[2].enableLeechSize).toBe(false);
const svc2 = cleaned[0].services[1];
expect(svc2.weight).toBe(0);
expect(svc2.widgets).toHaveLength(1);
expect(svc2.widgets[0]).toEqual(
expect.objectContaining({
type: "openwrt",
interfaceName: "eth0",
service_group: "Group",
service_name: "svc2",
index: 0,
}),
);
const svc3 = cleaned[0].services[2];
expect(svc3.weight).toBe(7);
expect(svc3.widgets[0]).toEqual(expect.objectContaining({ type: "frigate", enableRecentEvents: true }));
expect(state.logger.error).toHaveBeenCalled();
});
it("cleanServiceGroups applies widget-type specific mappings for commonly used widgets", async () => {
const mod = await import("./service-helpers");
const { cleanServiceGroups } = mod;
const rawGroups = [
{
name: "Core",
services: [
{
name: "svc",
weight: 100,
widgets: [
{ type: "azuredevops", userEmail: "u@example.com", repositoryId: "r" },
{ type: "beszel", version: "2", systemId: "sys" },
{ type: "coinmarketcap", currency: "USD", symbols: "BTC", slugs: "bitcoin", defaultinterval: "1d" },
{ type: "crowdsec", limit24h: "true" },
{ type: "docker", server: "docker-local", container: "c1" },
{ type: "unifi", site: "Home" },
{ type: "proxmox", node: "pve" },
{ type: "proxmoxbackupserver", datastore: "ds" },
{ type: "komodo", showSummary: "true", showStacks: "false" },
{ type: "kubernetes", namespace: "default", app: "app", podSelector: "app=test" },
{
type: "iframe",
src: "https://example.com",
allowFullscreen: true,
allowPolicy: "geolocation",
allowScrolling: false,
classes: "x",
loadingStrategy: "lazy",
referrerPolicy: "no-referrer",
refreshInterval: 1000,
},
{ type: "qbittorrent", enableLeechProgress: "true", enableLeechSize: "true" },
{ type: "opnsense", wan: "wan1" },
{ type: "emby", enableBlocks: "true", enableNowPlaying: "false", enableMediaControl: "true" },
{ type: "tautulli", expandOneStreamToTwoRows: "true", showEpisodeNumber: "true", enableUser: "true" },
{ type: "radarr", enableQueue: "true" },
{ type: "truenas", enablePools: "true", nasType: "scale" },
{ type: "qnap", volume: "vol1" },
{ type: "dispatcharr", enableActiveStreams: "true" },
{ type: "gamedig", gameToken: "t" },
{ type: "kopia", snapshotHost: "h", snapshotPath: "/p" },
{ type: "glances", version: "4", metric: "cpu", refreshInterval: 2000, pointsLimit: 5, diskUnits: "gb" },
{ type: "mjpeg", stream: "s", fit: "contain" },
{ type: "openmediavault", method: "foo.bar" },
{ type: "customapi", mappings: { x: 1 }, display: { y: 2 }, refreshInterval: 5000 },
{
type: "calendar",
integrations: [],
firstDayInWeek: "monday",
view: "agenda",
maxEvents: 10,
previousDays: 2,
showTime: true,
timezone: "UTC",
},
{ type: "dockhand", environment: "prod" },
{ type: "hdhomerun", tuner: 1 },
{ type: "healthchecks", uuid: "u" },
{ type: "speedtest", bitratePrecision: "3", version: "1" },
{ type: "stocks", watchlist: "AAPL", showUSMarketStatus: true },
{ type: "wgeasy", threshold: "10", version: "1" },
{ type: "technitium", range: "24h" },
{ type: "lubelogger", vehicleID: "12" },
{ type: "vikunja", enableTaskList: true, version: "1" },
{ type: "prometheusmetric", metrics: [], refreshInterval: 2500 },
{ type: "spoolman", spoolIds: [1, 2] },
{ type: "jellystat", days: "7" },
{ type: "grafana", alerts: [] },
{ type: "unraid", pool1: "a", pool2: "b", pool3: "c", pool4: "d" },
{ type: "yourspotify", interval: "daily" },
],
},
],
groups: [],
},
];
const cleaned = cleanServiceGroups(rawGroups);
const widgets = cleaned[0].services[0].widgets;
expect(widgets.find((w) => w.type === "azuredevops")).toEqual(
expect.objectContaining({ userEmail: "u@example.com", repositoryId: "r" }),
);
expect(widgets.find((w) => w.type === "beszel")).toEqual(expect.objectContaining({ version: 2, systemId: "sys" }));
expect(widgets.find((w) => w.type === "crowdsec")).toEqual(expect.objectContaining({ limit24h: true }));
expect(widgets.find((w) => w.type === "docker")).toEqual(
expect.objectContaining({ server: "docker-local", container: "c1" }),
);
expect(widgets.find((w) => w.type === "komodo")).toEqual(
expect.objectContaining({ showSummary: true, showStacks: false }),
);
expect(widgets.find((w) => w.type === "kubernetes")).toEqual(
expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }),
);
expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" }));
expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }),
);
expect(widgets.find((w) => w.type === "jellystat")).toEqual(expect.objectContaining({ days: 7 }));
expect(widgets.find((w) => w.type === "lubelogger")).toEqual(expect.objectContaining({ vehicleID: 12 }));
});
it("findGroupByName deep-searches and annotates parent", async () => {
const mod = await import("./service-helpers");
const { findGroupByName } = mod;
const groups = [
{
name: "Parent",
groups: [{ name: "Child", services: [], groups: [] }],
services: [],
},
];
const found = findGroupByName(groups, "Child");
expect(found.name).toBe("Child");
expect(found.parent).toBe("Parent");
});
it("getServiceItem prefers configured services over docker/kubernetes", async () => {
// Service present in config -> should return early (no Docker init).
state.servicesYaml = [{ G: [{ S: { icon: "x" } }] }];
const mod = await import("./service-helpers");
const serviceItem = await mod.getServiceItem("G", "S");
expect(serviceItem).toEqual(expect.objectContaining({ name: "S", type: "service", icon: "x" }));
expect(Docker).not.toHaveBeenCalled();
expect(kubeCfg.getKubeConfig).not.toHaveBeenCalled();
});
it("getServiceItem falls back to docker then kubernetes", async () => {
const mod = await import("./service-helpers");
// Miss in config, hit in Docker.
state.servicesYaml = [{ G: [{ Other: { icon: "nope" } }] }];
state.dockerYaml = { "docker-local": {} };
state.dockerContainers = [
{
Names: ["/c1"],
Labels: {
"homepage.group": "G",
"homepage.name": "S",
},
},
];
expect(await mod.getServiceItem("G", "S")).toEqual(
expect.objectContaining({ name: "S", server: "docker-local", container: "c1" }),
);
// Miss in config, miss in Docker, hit in Kubernetes.
vi.resetModules();
state.servicesYaml = [{ G: [{ Other: { icon: "nope" } }] }];
state.dockerYaml = { "docker-local": {} };
state.dockerContainers = [];
state.kubeConfig = {}; // truthy => proceed
state.kubeServices = [{ name: "S", group: "G", type: "service" }];
kubeApi.listIngress.mockResolvedValueOnce([{}]);
const mod2 = await import("./service-helpers");
expect(await mod2.getServiceItem("G", "S")).toEqual(expect.objectContaining({ name: "S", type: "service" }));
});
it("getServiceItem returns false when the service cannot be found anywhere", async () => {
state.servicesYaml = null;
state.dockerYaml = null;
state.kubeConfig = null;
const mod = await import("./service-helpers");
expect(await mod.getServiceItem("MissingGroup", "MissingService")).toBe(false);
});
it("getServiceWidget returns false when the widget cannot be found", async () => {
state.servicesYaml = null;
state.dockerYaml = null;
state.kubeConfig = null;
const mod = await import("./service-helpers");
expect(await mod.default("MissingGroup", "MissingService", 0)).toBe(false);
});
it("getServiceWidget returns widget or widgets[index]", async () => {
state.servicesYaml = [
{
G: [
{
S: { widget: { id: "single" }, widgets: [{ id: "w0" }, { id: "w1" }] },
},
],
},
];
const mod = await import("./service-helpers");
expect(await mod.default("G", "S", -1)).toEqual({ id: "single" });
expect(await mod.default("G", "S", "1")).toEqual({ id: "w1" });
});
it("servicesFromDocker maps homepage labels to groups, filters instance-scoped labels, and parses widget version", async () => {
config.getSettings.mockReturnValue({ instanceName: "foo" });
state.dockerYaml = {
"docker-local": {},
"docker-swarm": { swarm: true },
};
state.dockerContainersByServer["docker-local"] = [
{
Names: ["/c1"],
Labels: {
"homepage.group": "G",
"homepage.name": "Svc",
"homepage.href": "http://svc",
"homepage.widget.version": "3",
"homepage.instance.foo.description": "Desc",
"homepage.instance.bar.description": "Ignore",
},
},
// Missing required labels -> should be skipped with an error.
{
Names: ["/bad"],
Labels: {
"homepage.group": "G",
},
},
];
state.dockerServicesByServer["docker-swarm"] = [
// Swarm service label format.
{
Spec: {
Name: "swarm1",
Labels: {
"homepage.group": "G2",
"homepage.name": "SwarmSvc",
"homepage.widgets[0].version": "2",
},
},
},
];
const mod = await import("./service-helpers");
const discoveredGroups = await mod.servicesFromDocker();
expect(discoveredGroups).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "G",
services: [
expect.objectContaining({
name: "Svc",
server: "docker-local",
container: "c1",
href: "http://svc",
description: "Desc",
widget: { version: 3 },
}),
],
}),
expect.objectContaining({
name: "G2",
services: [
expect.objectContaining({
name: "SwarmSvc",
server: "docker-swarm",
container: "swarm1",
widgets: [{ version: 2 }],
}),
],
}),
]),
);
// The instance.bar.* labels should be ignored when instanceName=foo.
expect(JSON.stringify(discoveredGroups)).not.toContain("Ignore");
expect(state.logger.error).toHaveBeenCalled();
});
it("servicesFromDocker tolerates per-server failures and still returns other results", async () => {
state.dockerYaml = { "docker-a": {}, "docker-b": {} };
Docker.mockImplementationOnce(() => {
throw new Error("boom");
});
state.dockerContainers = [{ Names: ["/c1"], Labels: { "homepage.group": "G", "homepage.name": "Svc" } }];
const mod = await import("./service-helpers");
const discoveredGroups = await mod.servicesFromDocker();
expect(discoveredGroups).toEqual([
{ name: "G", services: [expect.objectContaining({ name: "Svc", container: "c1" })] },
]);
expect(["docker-a", "docker-b"]).toContain(discoveredGroups[0].services[0].server);
expect(state.logger.error).toHaveBeenCalled();
});
it("servicesFromKubernetes returns [] when kubernetes is not configured", async () => {
state.kubeConfig = null;
const mod = await import("./service-helpers");
expect(await mod.servicesFromKubernetes()).toEqual([]);
});
it("servicesFromKubernetes maps discoverable resources into service groups", async () => {
config.getSettings.mockReturnValue({ instanceName: "foo" });
state.kubeConfig = {}; // truthy
kubeApi.listIngress.mockResolvedValueOnce([{ kind: "Ingress" }]);
kubeApi.isDiscoverable.mockReturnValueOnce(true);
state.kubeServices = [{ name: "S", group: "G", type: "service", href: "http://k" }];
const mod = await import("./service-helpers");
const groups = await mod.servicesFromKubernetes();
expect(groups).toEqual([
{
name: "G",
services: [{ name: "S", type: "service", href: "http://k" }],
},
]);
expect(kubeApi.isDiscoverable).toHaveBeenCalledWith({ kind: "Ingress" }, "foo");
});
it("servicesFromKubernetes logs and rethrows unexpected errors", async () => {
state.kubeConfig = {}; // truthy
kubeApi.listIngress.mockRejectedValueOnce(new Error("boom"));
const mod = await import("./service-helpers");
await expect(mod.servicesFromKubernetes()).rejects.toThrow("boom");
expect(state.logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { get, set } from "./shvl";
describe("utils/config/shvl", () => {
it("get reads nested paths with arrays and returns default when missing", () => {
const obj = { a: { b: [{ c: 1 }] } };
expect(get(obj, "a.b[0].c")).toBe(1);
expect(get(obj, "a.b[1].c", "dflt")).toBe("dflt");
});
it("set creates nested objects/arrays as needed", () => {
const obj = {};
set(obj, "a.b[0].c", 123);
expect(obj).toEqual({ a: { b: [{ c: 123 }] } });
});
it("set blocks prototype pollution", () => {
const obj = {};
set(obj, "__proto__.polluted", true);
set(obj, "a.__proto__.polluted", true);
set(obj, "constructor.prototype.polluted", true);
expect(obj.polluted).toBeUndefined();
expect({}.polluted).toBeUndefined();
expect(Object.prototype.polluted).toBeUndefined();
});
});

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fs, yaml, config } = vi.hoisted(() => ({
fs: {
readFile: vi.fn(),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
substituteEnvironmentVars: vi.fn((s) => s),
default: vi.fn(),
},
}));
vi.mock("fs", () => ({
promises: fs,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => config);
import { cleanWidgetGroups, getPrivateWidgetOptions, widgetsFromConfig } from "./widget-helpers";
describe("utils/config/widget-helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("widgetsFromConfig maps YAML into a typed widgets array with indices", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce([{ search: { provider: "google", url: "http://x", key: "k" } }]);
const widgets = await widgetsFromConfig();
expect(widgets).toEqual([
{
type: "search",
options: { index: 0, provider: "google", url: "http://x", key: "k" },
},
]);
});
it("cleanWidgetGroups removes private options and hides url except for search/glances", async () => {
const cleaned = await cleanWidgetGroups([
{ type: "search", options: { index: 0, url: "http://x", username: "u", password: "p" } },
{ type: "something", options: { index: 1, url: "http://y", key: "k", foo: 1 } },
{ type: "glances", options: { index: 2, url: "http://z", apiKey: "k", bar: 2 } },
]);
expect(cleaned[0].options.url).toBe("http://x");
expect(cleaned[0].options.username).toBeUndefined();
expect(cleaned[1].options.url).toBeUndefined();
expect(cleaned[1].options.key).toBeUndefined();
expect(cleaned[1].options.foo).toBe(1);
expect(cleaned[2].options.url).toBe("http://z");
expect(cleaned[2].options.apiKey).toBeUndefined();
});
it("getPrivateWidgetOptions returns private options for a specific widget", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u", password: "p", key: "k" } }]);
const options = await getPrivateWidgetOptions("search", 0);
expect(options).toEqual(
expect.objectContaining({
index: 0,
url: "http://x",
username: "u",
password: "p",
key: "k",
}),
);
// And the full list when no args are provided
fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u" } }]);
const all = await getPrivateWidgetOptions();
expect(Array.isArray(all)).toBe(true);
expect(all[0].options.url).toBe("http://x");
});
});

View File

@@ -17,7 +17,7 @@ const getInitialColor = () => {
export const ColorContext = createContext();
export function ColorProvider({ initialTheme, children }) {
const [color, setColor] = useState(getInitialColor);
const [color, setColor] = useState(() => initialTheme ?? getInitialColor());
const rawSetColor = (rawColor) => {
const root = window.document.documentElement;
@@ -30,9 +30,10 @@ export function ColorProvider({ initialTheme, children }) {
lastColor = rawColor;
};
if (initialTheme) {
rawSetColor(initialTheme);
}
useEffect(() => {
if (initialTheme !== undefined) setColor(initialTheme ?? getInitialColor());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTheme]);
useEffect(() => {
rawSetColor(color);

View File

@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useContext } from "react";
import { describe, expect, it } from "vitest";
import { ColorContext, ColorProvider } from "./color";
function Reader() {
const { color, setColor } = useContext(ColorContext);
return (
<div>
<div data-testid="value">{color}</div>
<button type="button" onClick={() => setColor("red")}>
red
</button>
</div>
);
}
describe("utils/contexts/color", () => {
it("initializes from localStorage and writes theme class + storage on updates", async () => {
localStorage.setItem("theme-color", "blue");
document.documentElement.className = "";
render(
<ColorProvider>
<Reader />
</ColorProvider>,
);
expect(screen.getByTestId("value")).toHaveTextContent("blue");
await waitFor(() => expect(document.documentElement.classList.contains("theme-blue")).toBe(true));
fireEvent.click(screen.getByRole("button", { name: "red" }));
await waitFor(() => expect(document.documentElement.classList.contains("theme-red")).toBe(true));
expect(localStorage.getItem("theme-color")).toBe("red");
});
it("defaults to slate when localStorage is empty", async () => {
localStorage.removeItem("theme-color");
document.documentElement.className = "";
render(
<ColorProvider>
<Reader />
</ColorProvider>,
);
expect(screen.getByTestId("value")).toHaveTextContent("slate");
await waitFor(() => expect(document.documentElement.classList.contains("theme-slate")).toBe(true));
});
});

View File

@@ -1,13 +1,13 @@
import { createContext, useMemo, useState } from "react";
import { createContext, useEffect, useMemo, useState } from "react";
export const SettingsContext = createContext();
export function SettingsProvider({ initialSettings, children }) {
const [settings, setSettings] = useState({});
const [settings, setSettings] = useState(() => initialSettings ?? {});
if (initialSettings) {
setSettings(initialSettings);
}
useEffect(() => {
if (initialSettings !== undefined) setSettings(initialSettings ?? {});
}, [initialSettings]);
const value = useMemo(() => ({ settings, setSettings }), [settings]);

View File

@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { useContext } from "react";
import { describe, expect, it } from "vitest";
import { SettingsContext, SettingsProvider } from "./settings";
function Reader() {
const { settings, setSettings } = useContext(SettingsContext);
return (
<div>
<div data-testid="value">{JSON.stringify(settings)}</div>
<button type="button" onClick={() => setSettings({ updated: true })}>
update
</button>
</div>
);
}
describe("utils/contexts/settings", () => {
it("provides initial settings and allows updates", () => {
render(
<SettingsProvider initialSettings={{ a: 1 }}>
<Reader />
</SettingsProvider>,
);
expect(screen.getByTestId("value")).toHaveTextContent('{"a":1}');
fireEvent.click(screen.getByRole("button", { name: "update" }));
expect(screen.getByTestId("value")).toHaveTextContent('{"updated":true}');
});
});

View File

@@ -1,13 +1,13 @@
import { createContext, useMemo, useState } from "react";
import { createContext, useEffect, useMemo, useState } from "react";
export const TabContext = createContext();
export function TabProvider({ initialTab, children }) {
const [activeTab, setActiveTab] = useState(false);
const [activeTab, setActiveTab] = useState(() => initialTab ?? false);
if (initialTab) {
setActiveTab(initialTab);
}
useEffect(() => {
if (initialTab !== undefined) setActiveTab(initialTab ?? false);
}, [initialTab]);
const value = useMemo(() => ({ activeTab, setActiveTab }), [activeTab]);

View File

@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { useContext } from "react";
import { describe, expect, it } from "vitest";
import { TabContext, TabProvider } from "./tab";
function Reader() {
const { activeTab, setActiveTab } = useContext(TabContext);
return (
<div>
<div data-testid="value">{String(activeTab)}</div>
<button type="button" onClick={() => setActiveTab("next")}>
next
</button>
</div>
);
}
describe("utils/contexts/tab", () => {
it("provides initial tab and allows updates", () => {
render(
<TabProvider initialTab="first">
<Reader />
</TabProvider>,
);
expect(screen.getByTestId("value")).toHaveTextContent("first");
fireEvent.click(screen.getByRole("button", { name: "next" }));
expect(screen.getByTestId("value")).toHaveTextContent("next");
});
});

View File

@@ -19,7 +19,7 @@ const getInitialTheme = () => {
export const ThemeContext = createContext();
export function ThemeProvider({ initialTheme, children }) {
const [theme, setTheme] = useState(getInitialTheme);
const [theme, setTheme] = useState(() => initialTheme ?? getInitialTheme());
const rawSetTheme = (rawTheme) => {
const root = window.document.documentElement;
@@ -31,9 +31,10 @@ export function ThemeProvider({ initialTheme, children }) {
localStorage.setItem("theme-mode", rawTheme);
};
if (initialTheme) {
rawSetTheme(initialTheme);
}
useEffect(() => {
if (initialTheme !== undefined) setTheme(initialTheme ?? getInitialTheme());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTheme]);
useEffect(() => {
rawSetTheme(theme);

View File

@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import { useContext } from "react";
import { describe, expect, it, vi } from "vitest";
import { ThemeContext, ThemeProvider } from "./theme";
function Reader() {
const { theme } = useContext(ThemeContext);
return <div data-testid="value">{theme}</div>;
}
describe("utils/contexts/theme", () => {
it("initializes from localStorage and writes html classes", async () => {
// jsdom doesn't implement matchMedia by default; ensure it exists for getInitialTheme.
window.matchMedia =
window.matchMedia || vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
localStorage.setItem("theme-mode", "light");
document.documentElement.className = "";
render(
<ThemeProvider>
<Reader />
</ThemeProvider>,
);
expect(screen.getByTestId("value")).toHaveTextContent("light");
await waitFor(() => expect(document.documentElement.classList.contains("light")).toBe(true));
expect(localStorage.getItem("theme-mode")).toBe("light");
});
it("falls back to prefers-color-scheme when localStorage is empty", async () => {
const matchMedia = vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
window.matchMedia = matchMedia;
localStorage.removeItem("theme-mode");
render(
<ThemeProvider>
<Reader />
</ThemeProvider>,
);
expect(matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
expect(screen.getByTestId("value")).toHaveTextContent("dark");
});
it("defaults to dark when prefers-color-scheme does not match", async () => {
const matchMedia = vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
window.matchMedia = matchMedia;
localStorage.removeItem("theme-mode");
render(
<ThemeProvider>
<Reader />
</ThemeProvider>,
);
expect(matchMedia).toHaveBeenCalledWith("(prefers-color-scheme: dark)");
expect(screen.getByTestId("value")).toHaveTextContent("dark");
await waitFor(() => expect(localStorage.getItem("theme-mode")).toBe("dark"));
});
});

View File

@@ -0,0 +1,191 @@
import { describe, expect, it } from "vitest";
import { buildHighlightConfig, evaluateHighlight, getHighlightClass } from "./highlights";
describe("utils/highlights", () => {
it("returns null when there are no levels and no fields to evaluate", () => {
const cfg = buildHighlightConfig(
null,
{
levels: { good: null, warn: null, danger: null },
},
"x",
);
expect(cfg).toBeNull();
});
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("normalizes field keys by trimming and skipping blank/null entries", () => {
const cfg = buildHighlightConfig(
null,
{
levels: { good: null, warn: null, danger: null, custom: "x" },
" cpu ": { numeric: { when: "gt", value: 1, level: "custom" } },
"": { numeric: { when: "gt", value: 1, level: "danger" } },
empty: null,
},
"resources",
);
expect(cfg.fields.cpu).toBeTruthy();
expect(cfg.fields["resources.cpu"]).toBeTruthy();
expect(cfg.fields.empty).toBeUndefined();
});
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 stringifies booleans and applies case-sensitive string rules", () => {
const cfg = buildHighlightConfig(null, {
enabled: { string: { when: "equals", value: "true", caseSensitive: true, level: "good" } },
suffix: { string: { when: "endsWith", value: "World", caseSensitive: true, level: "warn" } },
});
expect(evaluateHighlight("enabled", true, cfg)).toMatchObject({ level: "good", source: "string" });
expect(evaluateHighlight("suffix", "HelloWorld", cfg)).toMatchObject({ level: "warn", source: "string" });
expect(evaluateHighlight("suffix", "helloworld", cfg)).toBeNull();
});
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");
expect(getHighlightClass("missing", cfg)).toBeUndefined();
});
it("supports localized numeric parsing and between/outside operators (with negate)", () => {
const cfg = buildHighlightConfig(null, {
temp: {
numeric: [
{ when: "between", min: 1000.5, max: 1500.5, level: "warn" },
{ when: "outside", value: { min: 1234.5, max: 2234.5 }, level: "danger", negate: true },
],
},
});
// "1.234,56" should parse as 1234.56 and hit the between rule.
expect(evaluateHighlight("temp", "1.234,56", cfg)).toMatchObject({ level: "warn", source: "numeric" });
// Negated outside => inside the range should match.
expect(evaluateHighlight("temp", "2.000,00", cfg)).toMatchObject({ level: "danger", source: "numeric" });
});
it("supports numeric parsing for dot/comma thousands formats", () => {
const cfg = buildHighlightConfig(null, {
num: { numeric: { when: "eq", value: 1234.56, level: "good" } },
grouped: { numeric: { when: "eq", value: 1234567, level: "warn" } },
});
expect(evaluateHighlight("num", "1,234.56", cfg)).toMatchObject({ level: "good", source: "numeric" });
expect(evaluateHighlight("grouped", "1.234.567", cfg)).toMatchObject({ level: "warn", source: "numeric" });
});
it("supports regex string rules, including invalid regex patterns (ignored)", () => {
const cfg = buildHighlightConfig(null, {
status: {
string: [
{ when: "regex", value: "^up$", level: "good" },
{ when: "regex", value: "(", level: "danger" }, // invalid; should be ignored
{ when: "equals", value: "DOWN", level: "warn", caseSensitive: true },
],
},
});
expect(evaluateHighlight("status", "Up", cfg)).toMatchObject({ level: "good", source: "string" });
expect(evaluateHighlight("status", "DOWN", cfg)).toMatchObject({ level: "warn", source: "string" });
expect(evaluateHighlight("status", "Down", cfg)).toBeNull();
});
it("parses numeric strings with commas/dots/spaces and supports stringified numeric rule values", () => {
const cfg = buildHighlightConfig(null, {
// string numeric rule values go through toNumber()
gt: { numeric: { when: "gt", value: "5", level: "warn" } },
commaGrouped: { numeric: { when: "eq", value: 1234, level: "good" } },
commaDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } },
dotDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } },
spaceGrouped: { numeric: { when: "eq", value: 1234, level: "good" } },
});
expect(evaluateHighlight("gt", "6", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("commaGrouped", "1,234", cfg)).toMatchObject({ level: "good", source: "numeric" });
expect(evaluateHighlight("commaDecimal", "12,34", cfg)).toMatchObject({ level: "good", source: "numeric" });
// Include a space so Number(trimmed) fails and we exercise the dot parsing branch.
expect(evaluateHighlight("dotDecimal", "12 .34", cfg)).toMatchObject({ level: "good", source: "numeric" });
expect(evaluateHighlight("spaceGrouped", "1 234", cfg)).toMatchObject({ level: "good", source: "numeric" });
});
it("treats unparseable numeric formats as non-numeric", () => {
const cfg = buildHighlightConfig(null, {
num: { numeric: { when: "gt", value: 0, level: "warn" } },
});
// Invalid comma grouping should not be treated as numeric.
expect(evaluateHighlight("num", "1,2,3", cfg)).toBeNull();
// "1.2.3" is not a valid grouped or decimal number for our parser.
expect(evaluateHighlight("num", "1.2.3", cfg)).toBeNull();
// JSX-ish values should not be treated as numeric.
expect(evaluateHighlight("num", { props: { children: "x" } }, cfg)).toBeNull();
});
it("falls through numeric evaluation when numeric rules do not match", () => {
const cfg = buildHighlightConfig(null, {
status: {
numeric: { when: "gte", value: 100, level: "danger" },
string: { when: "includes", value: "ok", level: "good" },
},
});
// Numeric rule doesn't match, string rule does.
expect(evaluateHighlight("status", "ok", cfg)).toMatchObject({ level: "good", source: "string" });
});
it("stringifies numbers/bigints for string evaluation and ignores unknown numeric operators", () => {
const cfg = buildHighlightConfig(null, {
// unknown numeric operator should not match
weird: { numeric: { when: "nope", value: 1, level: "warn" } },
// bigint should stringify to match a string rule
big: { string: { when: "equals", value: "9007199254740993", level: "good", caseSensitive: true } },
});
expect(evaluateHighlight("weird", "10", cfg)).toBeNull();
expect(evaluateHighlight("big", 9007199254740993n, cfg)).toMatchObject({ level: "good", source: "string" });
});
});

View File

@@ -0,0 +1,27 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import useWindowFocus from "./window-focus";
function Fixture() {
const focused = useWindowFocus();
return <div data-testid="focused">{String(focused)}</div>;
}
describe("utils/hooks/window-focus", () => {
it("tracks focus/blur events", async () => {
vi.spyOn(document, "hasFocus").mockReturnValue(true);
render(<Fixture />);
expect(screen.getByTestId("focused")).toHaveTextContent("true");
window.dispatchEvent(new Event("blur"));
await waitFor(() => expect(screen.getByTestId("focused")).toHaveTextContent("false"));
window.dispatchEvent(new Event("focus"));
await waitFor(() => expect(screen.getByTestId("focused")).toHaveTextContent("true"));
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
const { listIngress, listTraefikIngress, listHttpRoute, isDiscoverable, constructedServiceFromResource } = vi.hoisted(
() => ({
listIngress: vi.fn(),
listTraefikIngress: vi.fn(),
listHttpRoute: vi.fn(),
isDiscoverable: vi.fn(),
constructedServiceFromResource: vi.fn(),
}),
);
vi.mock("utils/kubernetes/ingress-list", () => ({ default: listIngress }));
vi.mock("utils/kubernetes/traefik-list", () => ({ default: listTraefikIngress }));
vi.mock("utils/kubernetes/httproute-list", () => ({ default: listHttpRoute }));
vi.mock("utils/kubernetes/resource-helpers", () => ({ isDiscoverable, constructedServiceFromResource }));
import kubernetes from "./export";
describe("utils/kubernetes/export", () => {
it("re-exports kubernetes helper functions", () => {
expect(kubernetes.listIngress).toBe(listIngress);
expect(kubernetes.listTraefikIngress).toBe(listTraefikIngress);
expect(kubernetes.listHttpRoute).toBe(listHttpRoute);
expect(kubernetes.isDiscoverable).toBe(isDiscoverable);
expect(kubernetes.constructedServiceFromResource).toBe(constructedServiceFromResource);
});
});

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { state, getKubernetes, getKubeConfig, logger } = vi.hoisted(() => {
const state = {
enabled: true,
namespaces: ["a", "b"],
routesByNs: {
a: [{ metadata: { name: "r1" } }],
b: [{ metadata: { name: "r2" } }],
},
crd: {
listNamespacedCustomObject: vi.fn(async ({ namespace }) => ({ items: state.routesByNs[namespace] ?? [] })),
},
core: {
listNamespace: vi.fn(async () => ({ items: state.namespaces.map((n) => ({ metadata: { name: n } })) })),
},
kc: {
makeApiClient: vi.fn((Api) => (Api.name === "CoreV1Api" ? state.core : state.crd)),
},
};
return {
state,
getKubernetes: vi.fn(() => ({ gateway: state.enabled })),
getKubeConfig: vi.fn(() => state.kc),
logger: { error: vi.fn(), debug: vi.fn() },
};
});
vi.mock("@kubernetes/client-node", () => ({
CoreV1Api: class CoreV1Api {},
CustomObjectsApi: class CustomObjectsApi {},
}));
vi.mock("utils/config/kubernetes", () => ({
getKubeConfig,
getKubernetes,
HTTPROUTE_API_GROUP: "gateway.networking.k8s.io",
HTTPROUTE_API_VERSION: "v1",
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
describe("utils/kubernetes/httproute-list", () => {
beforeEach(() => {
vi.clearAllMocks();
state.enabled = true;
state.namespaces = ["a", "b"];
state.routesByNs = {
a: [{ metadata: { name: "r1" } }],
b: [{ metadata: { name: "r2" } }],
};
});
it("returns an empty list when gateway discovery is disabled", async () => {
state.enabled = false;
vi.resetModules();
const listHttpRoute = (await import("./httproute-list")).default;
const result = await listHttpRoute();
expect(result).toEqual([]);
});
it("lists namespaces and aggregates httproutes", async () => {
vi.resetModules();
const listHttpRoute = (await import("./httproute-list")).default;
const result = await listHttpRoute();
expect(result.map((r) => r.metadata.name)).toEqual(["r1", "r2"]);
expect(state.core.listNamespace).toHaveBeenCalled();
expect(state.crd.listNamespacedCustomObject).toHaveBeenCalledTimes(2);
});
it("logs and returns [] when namespace listing fails", async () => {
state.core.listNamespace.mockRejectedValueOnce({ statusCode: 500, body: "boom", response: "resp" });
vi.resetModules();
const listHttpRoute = (await import("./httproute-list")).default;
const result = await listHttpRoute();
expect(result).toEqual([]);
expect(logger.error).toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalled();
});
it("skips namespaces whose httproute queries fail", async () => {
state.crd.listNamespacedCustomObject.mockImplementation(async ({ namespace }) => {
if (namespace === "b") throw { statusCode: 500, body: "boom", response: "resp" };
return { items: state.routesByNs[namespace] ?? [] };
});
vi.resetModules();
const listHttpRoute = (await import("./httproute-list")).default;
const result = await listHttpRoute();
expect(result.map((r) => r.metadata.name)).toEqual(["r1"]);
expect(logger.error).toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalled();
});
});

View File

@@ -20,7 +20,7 @@ export default async function listIngress() {
logger.debug(error);
return null;
});
ingressList = ingressData.items;
ingressList = ingressData?.items ?? [];
}
return ingressList;
}

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { state, getKubernetes, getKubeConfig, logger } = vi.hoisted(() => {
const state = {
ingressEnabled: true,
items: [],
throw: null,
networking: {
listIngressForAllNamespaces: vi.fn(async () => {
if (state.throw) throw state.throw;
return { items: state.items };
}),
},
kc: {
makeApiClient: vi.fn(() => state.networking),
},
};
return {
state,
getKubernetes: vi.fn(() => ({ ingress: state.ingressEnabled })),
getKubeConfig: vi.fn(() => state.kc),
logger: { error: vi.fn(), debug: vi.fn() },
};
});
vi.mock("@kubernetes/client-node", () => ({
NetworkingV1Api: class NetworkingV1Api {},
}));
vi.mock("utils/config/kubernetes", () => ({
getKubernetes,
getKubeConfig,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
describe("utils/kubernetes/ingress-list", () => {
beforeEach(() => {
vi.clearAllMocks();
state.ingressEnabled = true;
state.items = [];
state.throw = null;
});
it("returns an empty list when ingress discovery is disabled", async () => {
state.ingressEnabled = false;
vi.resetModules();
const listIngress = (await import("./ingress-list")).default;
const result = await listIngress();
expect(result).toEqual([]);
expect(state.networking.listIngressForAllNamespaces).not.toHaveBeenCalled();
});
it("returns items from listIngressForAllNamespaces", async () => {
state.items = [{ metadata: { name: "i1" } }];
vi.resetModules();
const listIngress = (await import("./ingress-list")).default;
const result = await listIngress();
expect(result).toEqual([{ metadata: { name: "i1" } }]);
});
it("returns an empty list on errors", async () => {
state.throw = { statusCode: 500, body: "nope", response: "x" };
vi.resetModules();
const listIngress = (await import("./ingress-list")).default;
const result = await listIngress();
expect(result).toEqual([]);
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { state, substituteEnvironmentVars, getKubeConfig, logger } = vi.hoisted(() => {
const state = {
gatewayProtocol: "https",
};
const substituteEnvironmentVars = vi.fn((raw) =>
raw.replaceAll("${DESC}", process.env.DESC ?? "").replaceAll("${ICON}", process.env.ICON ?? ""),
);
const crd = {
getNamespacedCustomObject: vi.fn(async () => ({
spec: { listeners: [{ name: "web", protocol: state.gatewayProtocol.toUpperCase() }] },
})),
};
const kc = {
makeApiClient: vi.fn(() => crd),
};
return {
state,
substituteEnvironmentVars,
getKubeConfig: vi.fn(() => kc),
logger: { error: vi.fn(), debug: vi.fn() },
};
});
vi.mock("@kubernetes/client-node", () => ({
CustomObjectsApi: class CustomObjectsApi {},
}));
vi.mock("utils/config/config", () => ({
substituteEnvironmentVars,
}));
vi.mock("utils/config/kubernetes", () => ({
ANNOTATION_BASE: "gethomepage.dev",
ANNOTATION_WIDGET_BASE: "gethomepage.dev/widget.",
HTTPROUTE_API_GROUP: "gateway.networking.k8s.io",
HTTPROUTE_API_VERSION: "v1",
getKubeConfig,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
import { constructedServiceFromResource, isDiscoverable } from "./resource-helpers";
describe("utils/kubernetes/resource-helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.DESC = "desc";
process.env.ICON = "mdi:test";
state.gatewayProtocol = "https";
});
it("checks discoverability by annotations and instance", () => {
const base = "gethomepage.dev";
const resource = { metadata: { annotations: { [`${base}/enabled`]: "true" } } };
expect(isDiscoverable(resource, "x")).toBe(true);
expect(isDiscoverable({ metadata: { annotations: { [`${base}/enabled`]: "false" } } }, "x")).toBe(false);
expect(
isDiscoverable({ metadata: { annotations: { [`${base}/enabled`]: "true", [`${base}/instance`]: "x" } } }, "x"),
).toBe(true);
expect(
isDiscoverable({ metadata: { annotations: { [`${base}/enabled`]: "true", [`${base}/instance.y`]: "1" } } }, "y"),
).toBe(true);
});
it("constructs a service from an ingress and applies widget annotations + env substitution", async () => {
const base = "gethomepage.dev";
const resource = {
kind: "Ingress",
metadata: {
name: "app",
namespace: "ns",
annotations: {
[`${base}/external`]: "TRUE",
[`${base}/description`]: "${DESC}",
[`${base}/icon`]: "${ICON}",
[`${base}/pod-selector`]: "app=test",
[`${base}/ping`]: "http://example.com/ping",
[`${base}/siteMonitor`]: "http://example.com/health",
[`${base}/statusStyle`]: "dot",
[`${base}/widget.type`]: "kubernetes",
[`${base}/widget.url`]: "http://x",
},
},
spec: {
tls: [{}],
rules: [{ host: "example.com", http: { paths: [{ path: "/app" }] } }],
},
};
const service = await constructedServiceFromResource(resource);
expect(service.href).toBe("https://example.com/app");
expect(service.external).toBe(true);
expect(service.description).toBe("desc");
expect(service.icon).toBe("mdi:test");
expect(service.podSelector).toBe("app=test");
expect(service.ping).toBe("http://example.com/ping");
expect(service.siteMonitor).toBe("http://example.com/health");
expect(service.statusStyle).toBe("dot");
expect(service.widget.type).toBe("kubernetes");
expect(service.widget.url).toBe("http://x");
expect(substituteEnvironmentVars).toHaveBeenCalled();
});
it("constructs a href from an HTTPRoute using the gateway listener protocol", async () => {
const base = "gethomepage.dev";
const resource = {
kind: "HTTPRoute",
metadata: {
name: "route",
namespace: "ns",
annotations: {
[`${base}/enabled`]: "true",
},
},
spec: {
hostnames: ["example.com"],
parentRefs: [{ namespace: "ns", name: "gw", sectionName: "web" }],
rules: [
{
matches: [{ path: { type: "PathPrefix", value: "/r" } }],
},
],
},
};
const service = await constructedServiceFromResource(resource);
expect(service.href).toBe("https://example.com/r");
});
it("falls back to http when the gateway listener protocol cannot be resolved", async () => {
const kc = getKubeConfig();
const crd = kc.makeApiClient();
crd.getNamespacedCustomObject.mockRejectedValueOnce({
statusCode: 500,
body: "boom",
response: "resp",
});
const base = "gethomepage.dev";
const resource = {
kind: "HTTPRoute",
metadata: {
name: "route",
namespace: "ns",
annotations: {
[`${base}/enabled`]: "true",
},
},
spec: {
hostnames: ["example.com"],
parentRefs: [{ namespace: "ns", name: "gw", sectionName: "web" }],
rules: [
{
matches: [{ path: { type: "PathPrefix", value: "/r" } }],
},
],
},
};
const service = await constructedServiceFromResource(resource);
expect(service.href).toBe("http://example.com/r");
expect(logger.error).toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalled();
});
it("logs and recovers when environment substitution yields invalid json", async () => {
substituteEnvironmentVars.mockImplementationOnce(() => "{bad json");
const base = "gethomepage.dev";
const resource = {
kind: "Ingress",
metadata: {
name: "app",
namespace: "ns",
annotations: {
[`${base}/enabled`]: "true",
},
},
spec: {
rules: [{ host: "example.com", http: { paths: [{ path: "/app" }] } }],
},
};
const service = await constructedServiceFromResource(resource);
expect(service.name).toBe("app");
expect(logger.error).toHaveBeenCalledWith("Error attempting k8s environment variable substitution.");
expect(logger.debug).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { state, getKubernetes, getKubeConfig, checkCRD, logger } = vi.hoisted(() => {
const state = {
enabled: true,
containoItems: [],
ioItems: [],
crd: {
listClusterCustomObject: vi.fn(async ({ group }) => {
if (group === "traefik.containo.us") return { items: state.containoItems };
if (group === "traefik.io") return { items: state.ioItems };
return { items: [] };
}),
},
kc: {
makeApiClient: vi.fn(() => state.crd),
},
};
return {
state,
getKubernetes: vi.fn(() => ({ traefik: state.enabled })),
getKubeConfig: vi.fn(() => state.kc),
checkCRD: vi.fn(async () => true),
logger: { error: vi.fn(), debug: vi.fn() },
};
});
vi.mock("@kubernetes/client-node", () => ({
CustomObjectsApi: class CustomObjectsApi {},
}));
vi.mock("utils/config/kubernetes", () => ({
ANNOTATION_BASE: "gethomepage.dev",
checkCRD,
getKubeConfig,
getKubernetes,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
describe("utils/kubernetes/traefik-list", () => {
beforeEach(() => {
vi.clearAllMocks();
state.enabled = true;
state.containoItems = [];
state.ioItems = [];
state.crd.listClusterCustomObject.mockImplementation(async ({ group }) => {
if (group === "traefik.containo.us") return { items: state.containoItems };
if (group === "traefik.io") return { items: state.ioItems };
return { items: [] };
});
checkCRD.mockResolvedValue(true);
});
it("returns an empty list when traefik discovery is disabled", async () => {
state.enabled = false;
vi.resetModules();
const listTraefikIngress = (await import("./traefik-list")).default;
const result = await listTraefikIngress();
expect(result).toEqual([]);
});
it("filters and merges ingressroutes with homepage href annotations", async () => {
state.containoItems = [
{ metadata: { annotations: { "gethomepage.dev/href": "http://a" } } },
{ metadata: { annotations: {} } },
];
state.ioItems = [{ metadata: { annotations: { "gethomepage.dev/href": "http://b" } } }];
vi.resetModules();
const listTraefikIngress = (await import("./traefik-list")).default;
const result = await listTraefikIngress();
expect(result).toHaveLength(2);
expect(result[0].metadata.annotations["gethomepage.dev/href"]).toBe("http://a");
expect(result[1].metadata.annotations["gethomepage.dev/href"]).toBe("http://b");
expect(checkCRD).toHaveBeenCalled();
});
it("logs errors when traefik CRDs exist and the API calls fail", async () => {
const err = { statusCode: 500, body: "nope", response: "nope" };
state.crd.listClusterCustomObject.mockRejectedValue(err);
vi.resetModules();
const listTraefikIngress = (await import("./traefik-list")).default;
const result = await listTraefikIngress();
expect(result).toEqual([]);
expect(logger.error).toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(err);
});
it("suppresses API errors when the CRD is not installed", async () => {
checkCRD.mockResolvedValue(false);
state.crd.listClusterCustomObject.mockRejectedValue({ statusCode: 500 });
vi.resetModules();
const listTraefikIngress = (await import("./traefik-list")).default;
const result = await listTraefikIngress();
expect(result).toEqual([]);
expect(logger.error).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { parseCpu, parseMemory } from "./utils";
describe("utils/kubernetes/utils", () => {
it("parses cpu units into core values", () => {
expect(parseCpu("500m")).toBeCloseTo(0.5);
expect(parseCpu("250u")).toBeCloseTo(0.00025);
expect(parseCpu("1000n")).toBeCloseTo(0.000001);
expect(parseCpu("5x")).toBe(5);
expect(parseCpu("2")).toBe(2);
});
it("parses memory units into numeric values", () => {
expect(parseMemory("1Gi")).toBe(1000000000);
expect(parseMemory("1G")).toBe(1024 * 1024 * 1024);
expect(parseMemory("1Mi")).toBe(1000000);
expect(parseMemory("1M")).toBe(1024 * 1024);
expect(parseMemory("1Ki")).toBe(1000);
expect(parseMemory("1K")).toBe(1024);
expect(parseMemory("3Ti")).toBe(3);
expect(parseMemory("256")).toBe(256);
});
});

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { columnMap } from "./columns";
describe("utils/layout/columns", () => {
it("maps column counts to responsive grid classes", () => {
expect(columnMap).toHaveLength(9);
expect(columnMap[1]).toContain("grid-cols-1");
expect(columnMap[2]).toContain("md:grid-cols-2");
expect(columnMap[8]).toContain("lg:grid-cols-8");
});
});

182
src/utils/logger.test.js Normal file
View File

@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { state, winston, checkAndCopyConfig, getSettings } = vi.hoisted(() => {
const state = {
created: [],
lastCreateLoggerArgs: null,
};
function ConsoleTransport(opts) {
this.opts = opts;
}
function FileTransport(opts) {
this.opts = opts;
}
const createLogger = vi.fn((args) => {
state.lastCreateLoggerArgs = args;
const base = {
child: vi.fn(() => base),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
state.created.push(base);
return base;
});
const winston = {
transports: { Console: ConsoleTransport, File: FileTransport },
format: {
combine: (...parts) => ({ parts }),
errors: () => ({}),
timestamp: () => ({}),
colorize: () => ({}),
printf: (fn) => fn,
},
createLogger,
};
return {
state,
winston,
checkAndCopyConfig: vi.fn(),
getSettings: vi.fn(() => ({ logpath: "/tmp" })),
};
});
vi.mock("winston", () => ({ default: winston, ...winston }));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
CONF_DIR: "/conf",
getSettings,
}));
describe("utils/logger", () => {
const originalEnv = process.env;
const originalConsole = { ...console };
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
});
afterEach(() => {
// Restore patched console methods if init() ran.
Object.assign(console, originalConsole);
});
it("initializes winston on first createLogger() and caches per label", async () => {
vi.resetModules();
process.env.LOG_TARGETS = "stdout";
const createLogger = (await import("./logger")).default;
const a1 = createLogger("a");
const a2 = createLogger("a");
const b = createLogger("b");
expect(checkAndCopyConfig).toHaveBeenCalledWith("settings.yaml");
expect(winston.createLogger).toHaveBeenCalled();
expect(a1).toBe(a2);
expect(b).toBeDefined();
});
it("selects stdout/file/both transports based on LOG_TARGETS", async () => {
vi.resetModules();
process.env.LOG_TARGETS = "file";
const createLogger = (await import("./logger")).default;
createLogger("x");
const transports = state.lastCreateLoggerArgs.transports;
expect(transports).toHaveLength(1);
expect(transports[0].opts.filename).toBe("/tmp/logs/homepage.log");
});
it("defaults to both transports for unknown LOG_TARGETS and patches console methods", async () => {
vi.resetModules();
process.env.LOG_TARGETS = "wat";
const createLogger = (await import("./logger")).default;
const instance = createLogger("x");
const transports = state.lastCreateLoggerArgs.transports;
expect(transports).toHaveLength(2);
console.log("hello");
expect(instance.info).toHaveBeenCalledWith("hello");
});
it("uses CONF_DIR as the default logpath when settings.logpath is not set", async () => {
vi.resetModules();
process.env.LOG_TARGETS = "file";
getSettings.mockReturnValueOnce({});
const createLogger = (await import("./logger")).default;
createLogger("x");
const transports = state.lastCreateLoggerArgs.transports;
expect(transports[0].opts.filename).toBe("/conf/logs/homepage.log");
});
it("formats messages and stacks through the printf formatter", async () => {
vi.resetModules();
process.env.LOG_TARGETS = "stdout";
process.env.LOG_LEVEL = "debug";
const createLogger = (await import("./logger")).default;
createLogger("x");
expect(state.lastCreateLoggerArgs.level).toBe("debug");
const [consoleTransport] = state.lastCreateLoggerArgs.transports;
const parts = consoleTransport.opts.format.parts;
const formatter = parts.find((p) => typeof p === "function");
const splat = parts.find((p) => p && typeof p.transform === "function");
const msg = formatter({
timestamp: "t",
level: "info",
label: "x",
message: "hello",
});
expect(msg).toBe("[t] info: <x> hello");
const labelStackMsg = formatter({
timestamp: "t",
level: "error",
label: "x",
stack: "STACK",
message: "ignored",
});
expect(labelStackMsg).toBe("[t] error: <x> STACK");
const stackMsg = formatter({
timestamp: "t",
level: "error",
stack: "STACK",
message: "ignored",
});
expect(stackMsg).toBe("[t] error: STACK");
const plainMsg = formatter({
timestamp: "t",
level: "info",
message: "hello",
});
expect(plainMsg).toBe("[t] info: hello");
const out = splat.transform(
{
message: "Hello %s",
[Symbol.for("splat")]: ["World"],
},
{},
);
expect(out.message).toBe("Hello World");
});
});

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
asJson,
formatApiCall,
formatProxyUrl,
getURLSearchParams,
jsonArrayFilter,
jsonArrayTransform,
sanitizeErrorURL,
} from "./api-helpers";
describe("utils/proxy/api-helpers", () => {
it("formatApiCall replaces placeholders and trims trailing slashes for {url}", () => {
expect(formatApiCall("{url}/{endpoint}", { url: "http://localhost///", endpoint: "api" })).toBe(
"http://localhost/api",
);
});
it("formatApiCall replaces repeated placeholders", () => {
expect(formatApiCall("{a}-{a}-{missing}", { a: "x" })).toBe("x-x-");
});
it("getURLSearchParams includes group/service/index and optionally endpoint", () => {
const widget = { service_group: "g", service_name: "s", index: "0" };
const withEndpoint = getURLSearchParams(widget, "stats");
expect(withEndpoint.get("group")).toBe("g");
expect(withEndpoint.get("service")).toBe("s");
expect(withEndpoint.get("index")).toBe("0");
expect(withEndpoint.get("endpoint")).toBe("stats");
const withoutEndpoint = getURLSearchParams(widget);
expect(withoutEndpoint.get("endpoint")).toBeNull();
});
it("formatProxyUrl builds expected proxy URL and encodes query params", () => {
const widget = { service_group: "g", service_name: "s", index: "2" };
const url = formatProxyUrl(widget, "health", { a: 1, b: "x" });
expect(url.startsWith("/api/services/proxy?")).toBe(true);
const qs = url.split("?")[1];
const params = new URLSearchParams(qs);
expect(params.get("group")).toBe("g");
expect(params.get("service")).toBe("s");
expect(params.get("index")).toBe("2");
expect(params.get("endpoint")).toBe("health");
expect(JSON.parse(params.get("query"))).toEqual({ a: 1, b: "x" });
});
it("asJson parses JSON buffers and returns non-JSON values unchanged", () => {
expect(asJson(Buffer.from(JSON.stringify({ ok: true })))).toEqual({ ok: true });
expect(asJson(Buffer.from(""))).toEqual(Buffer.from(""));
expect(asJson(null)).toBeNull();
});
it("jsonArrayTransform transforms arrays and returns non-arrays unchanged", () => {
const data = Buffer.from(JSON.stringify([{ a: 1 }, { a: 2 }]));
expect(jsonArrayTransform(data, (items) => items.map((i) => i.a))).toEqual([1, 2]);
expect(jsonArrayTransform(Buffer.from(JSON.stringify({ ok: true })), () => "nope")).toEqual({ ok: true });
});
it("jsonArrayFilter filters arrays and returns non-arrays unchanged", () => {
const data = Buffer.from(JSON.stringify([{ a: 1 }, { a: 2 }]));
expect(jsonArrayFilter(data, (item) => item.a > 1)).toEqual([{ a: 2 }]);
});
it("sanitizeErrorURL redacts sensitive query params and hash fragments", () => {
const input = "https://example.com/path?apikey=123&token=abc#access_token=xyz&other=1";
const output = sanitizeErrorURL(input);
const url = new URL(output);
expect(url.searchParams.get("apikey")).toBe("***");
expect(url.searchParams.get("token")).toBe("***");
expect(url.hash).toContain("access_token=***");
expect(url.hash).toContain("other=1");
});
it("sanitizeErrorURL only redacts known keys", () => {
const input = "https://example.com/path?api_key=123&safe=ok#auth=abc&safe_hash=1";
const output = sanitizeErrorURL(input);
const url = new URL(output);
expect(url.searchParams.get("api_key")).toBe("***");
expect(url.searchParams.get("safe")).toBe("ok");
expect(url.hash).toContain("auth=***");
expect(url.hash).toContain("safe_hash=1");
});
});

View File

@@ -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");
});
});

View File

@@ -18,6 +18,11 @@ export default async function credentialedProxyHandler(req, res, map) {
if (group && service) {
const widget = await getServiceWidget(group, service, index);
if (!widget) {
logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid proxy service type" });
}
if (!widgets?.[widget.type]?.api) {
return res.status(403).json({ error: "Service does not support API calls" });
}

View File

@@ -0,0 +1,404 @@
import { beforeEach, 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() }));
const { getSettings } = vi.hoisted(() => ({
getSettings: vi.fn(() => ({ providers: { finnhub: "finnhub-token" } })),
}));
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 }));
vi.mock("utils/config/config", () => ({ getSettings }));
// Keep the widget registry minimal so the test doesn't import the whole widget graph.
vi.mock("widgets/widgets", () => ({
default: {
coinmarketcap: { api: "{url}/{endpoint}" },
gotify: { api: "{url}/{endpoint}" },
plantit: { api: "{url}/{endpoint}" },
myspeed: { api: "{url}/{endpoint}" },
esphome: { api: "{url}/{endpoint}" },
wgeasy: { api: "{url}/{endpoint}" },
linkwarden: { api: "{url}/api/v1/{endpoint}" },
miniflux: { api: "{url}/{endpoint}" },
nextcloud: { api: "{url}/ocs/v2.php/apps/serverinfo/api/v1/{endpoint}" },
paperlessngx: { api: "{url}/api/{endpoint}" },
proxmox: { api: "{url}/api2/json/{endpoint}" },
truenas: { api: "{url}/api/v2.0/{endpoint}" },
proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" },
checkmk: { api: "{url}/{endpoint}" },
stocks: { api: "{url}/{endpoint}" },
speedtest: { api: "{url}/{endpoint}" },
tubearchivist: { api: "{url}/{endpoint}" },
autobrr: { api: "{url}/{endpoint}" },
jellystat: { api: "{url}/{endpoint}" },
trilium: { api: "{url}/{endpoint}" },
gitlab: { api: "{url}/{endpoint}" },
azuredevops: { api: "{url}/{endpoint}" },
glances: { api: "{url}/{endpoint}" },
withheaders: { api: "{url}/{endpoint}", headers: { "X-Widget": "1" } },
},
}));
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", () => {
beforeEach(() => {
vi.clearAllMocks();
validateWidgetData.mockReturnValue(true);
});
it("returns 400 when group/service are missing", async () => {
const req = { method: "GET", query: { endpoint: "e", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid proxy service type" });
});
it("returns 400 when the widget cannot be resolved", async () => {
getServiceWidget.mockResolvedValue(false);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid proxy service type" });
});
it("returns 403 when the widget type does not support API calls", async () => {
getServiceWidget.mockResolvedValue({ type: "noapi", url: "http://example", key: "token" });
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Service does not support API calls" });
});
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();
});
it("uses basic auth for nextcloud when key is not provided", async () => {
getServiceWidget.mockResolvedValue({ type: "nextcloud", url: "http://example", username: "u", password: "p" });
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.Authorization).toMatch(/^Basic /);
});
it("uses basic auth for truenas when key is not provided", async () => {
getServiceWidget.mockResolvedValue({ type: "truenas", url: "http://nas", username: "u", password: "p" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "system/info", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers.Authorization).toMatch(/^Basic /);
});
it("uses Bearer auth for truenas when key is provided", async () => {
getServiceWidget.mockResolvedValue({ type: "truenas", url: "http://nas", key: "k" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "system/info", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers.Authorization).toBe("Bearer k");
});
it.each([
[{ type: "paperlessngx", url: "http://x", key: "k" }, { Authorization: "Token k" }],
[
{ type: "paperlessngx", url: "http://x", username: "u", password: "p" },
{ Authorization: expect.stringMatching(/^Basic /) },
],
])("sets paperlessngx auth mode for %o", async (widget, expected) => {
getServiceWidget.mockResolvedValue(widget);
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "documents", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers).toEqual(expect.objectContaining(expected));
});
it("uses basic auth for esphome when username/password are provided", async () => {
getServiceWidget.mockResolvedValue({ type: "esphome", url: "http://x", username: "u", password: "p" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "e", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers.Authorization).toMatch(/^Basic /);
});
it("uses basic auth for wgeasy when username/password are provided", async () => {
getServiceWidget.mockResolvedValue({ type: "wgeasy", url: "http://x", username: "u", password: "p" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "e", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers.Authorization).toMatch(/^Basic /);
});
it("covers additional auth/header modes for common widgets", async () => {
const cases = [
[{ type: "coinmarketcap", url: "http://x", key: "k" }, { "X-CMC_PRO_API_KEY": "k" }],
[{ type: "gotify", url: "http://x", key: "k" }, { "X-gotify-Key": "k" }],
[{ type: "plantit", url: "http://x", key: "k" }, { Key: "k" }],
[{ type: "myspeed", url: "http://x", password: "p" }, { Password: "p" }],
[{ type: "proxmox", url: "http://x", username: "u", password: "p" }, { Authorization: "PVEAPIToken=u=p" }],
[{ type: "autobrr", url: "http://x", key: "k" }, { "X-API-Token": "k" }],
[{ type: "jellystat", url: "http://x", key: "k" }, { "X-API-Token": "k" }],
[{ type: "tubearchivist", url: "http://x", key: "k" }, { Authorization: "Token k" }],
[{ type: "miniflux", url: "http://x", key: "k" }, { "X-Auth-Token": "k" }],
[{ type: "trilium", url: "http://x", key: "k" }, { Authorization: "k" }],
[{ type: "gitlab", url: "http://x", key: "k" }, { "PRIVATE-TOKEN": "k" }],
[{ type: "speedtest", url: "http://x", key: "k" }, { Authorization: "Bearer k" }],
[
{ type: "azuredevops", url: "http://x", key: "k" },
{ Authorization: `Basic ${Buffer.from("$:k").toString("base64")}` },
],
[
{ type: "glances", url: "http://x", username: "u", password: "p" },
{ Authorization: expect.stringMatching(/^Basic /) },
],
[{ type: "wgeasy", url: "http://x", password: "p" }, { Authorization: "p" }],
[{ type: "esphome", url: "http://x", key: "cookie" }, { Cookie: "authenticated=cookie" }],
];
for (const [widget, expected] of cases) {
getServiceWidget.mockResolvedValue(widget);
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "e", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers).toEqual(expect.objectContaining(expected));
}
});
it("merges registry/widget/request headers and falls back to X-API-Key for unknown types", async () => {
getServiceWidget.mockResolvedValue({
type: "withheaders",
url: "http://example",
key: "k",
headers: { "X-From-Widget": "2" },
});
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = {
method: "GET",
query: { group: "g", service: "s", endpoint: "collections", index: 0 },
extraHeaders: { "X-From-Req": "3" },
};
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers).toEqual(
expect.objectContaining({
"Content-Type": "application/json",
"X-Widget": "1",
"X-From-Widget": "2",
"X-From-Req": "3",
"X-API-Key": "k",
}),
);
});
it("sets PBSAPIToken auth and removes content-type for proxmoxbackupserver", async () => {
getServiceWidget.mockResolvedValue({
type: "proxmoxbackupserver",
url: "http://pbs",
username: "u",
password: "p",
});
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "nodes", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers["Content-Type"]).toBeUndefined();
expect(params.headers.Authorization).toBe("PBSAPIToken=u:p");
});
it("uses checkmk's Bearer username password auth format", async () => {
getServiceWidget.mockResolvedValue({ type: "checkmk", url: "http://checkmk", username: "u", password: "p" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = {
method: "GET",
query: { group: "g", service: "s", endpoint: "domain-types/host_config/collections/all", index: 0 },
};
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers.Accept).toBe("application/json");
expect(params.headers.Authorization).toBe("Bearer u p");
});
it("injects the configured finnhub provider token for stocks widgets", async () => {
getServiceWidget.mockResolvedValue({ type: "stocks", url: "http://stocks", provider: "finnhub" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "quote", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
const [, params] = httpProxy.mock.calls.at(-1);
expect(params.headers["X-Finnhub-Token"]).toBe("finnhub-token");
});
it("sanitizes embedded query params when a downstream error contains a url", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
httpProxy.mockResolvedValue([500, "application/json", { error: { message: "oops", url: "http://bad" } }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections?apikey=secret", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body.error.url).toContain("apikey=***");
});
it("ends the response for 204/304 statuses", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
httpProxy.mockResolvedValue([204, "application/json", Buffer.from("")]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res);
expect(res.statusCode).toBe(204);
});
it("returns invalid data errors as 500 when validation fails on 200 responses", async () => {
validateWidgetData.mockReturnValueOnce(false);
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(res.statusCode).toBe(500);
expect(res.body.error.message).toBe("Invalid data");
expect(res.body.error.url).toContain("http://example/api/v1/collections");
});
it("applies the response mapping function when provided", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden", url: "http://example", key: "token" });
httpProxy.mockResolvedValue([200, "application/json", { ok: true, value: 1 }]);
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "collections", index: 0 } };
const res = createMockRes();
await credentialedProxyHandler(req, res, (data) => ({ ok: data.ok, v: data.value }));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ ok: true, v: 1 });
});
});

View File

@@ -0,0 +1,256 @@
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("returns 403 when the service widget type does not define an API mapping", async () => {
getServiceWidget.mockResolvedValue({
type: "missing",
url: "http://example",
});
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
const res = createMockRes();
await genericProxyHandler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Service does not support API calls" });
});
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 widget.requestBody as a string when req.body is not provided", async () => {
getServiceWidget.mockResolvedValue({
type: "testservice",
url: "http://example",
method: "POST",
requestBody: "raw-body",
});
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
const req = { method: "POST", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
const res = createMockRes();
await genericProxyHandler(req, res);
expect(httpProxy.mock.calls[0][1].body).toBe("raw-body");
});
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("sanitizes error urls embedded in successful payloads", async () => {
getServiceWidget.mockResolvedValue({
type: "testservice",
url: "http://example",
});
httpProxy.mockResolvedValueOnce([
200,
"application/json",
{
error: {
url: "http://upstream.example/?apikey=secret",
},
},
]);
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api?apikey=secret", index: "0" } };
const res = createMockRes();
await genericProxyHandler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body.error.url).toContain("apikey=***");
});
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");
});
it("uses string requestBody as-is and prefers req.body over widget.requestBody", async () => {
getServiceWidget.mockResolvedValue({
type: "testservice",
url: "http://example",
requestBody: '{"a":1}',
});
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
const req = {
method: "POST",
body: "override-body",
query: { group: "g", service: "svc", endpoint: "api", index: "0" },
};
const res = createMockRes();
await genericProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(1);
expect(httpProxy.mock.calls[0][1].body).toBe("override-body");
});
it("ends the response for 204/304 statuses", async () => {
getServiceWidget.mockResolvedValue({
type: "testservice",
url: "http://example",
});
httpProxy.mockResolvedValueOnce([204, "application/json", Buffer.from("")]);
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
const res = createMockRes();
await genericProxyHandler(req, res);
expect(res.statusCode).toBe(204);
expect(res.end).toHaveBeenCalled();
});
it("returns an HTTP Error object for status>=400 and stringifies buffer data", async () => {
getServiceWidget.mockResolvedValue({
type: "testservice",
url: "http://example",
});
httpProxy.mockResolvedValueOnce([500, "application/json", Buffer.from("fail")]);
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api?apikey=secret", index: "0" } };
const res = createMockRes();
await genericProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body.error.message).toBe("HTTP Error");
expect(res.body.error.url).toContain("apikey=***");
expect(res.body.error.data).toBe("fail");
});
it("returns 400 when group/service are missing", async () => {
const req = { method: "GET", query: { endpoint: "api", index: "0" } };
const res = createMockRes();
await genericProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid proxy service type" });
expect(logger.debug).toHaveBeenCalled();
});
it("applies the response mapping function when provided", async () => {
getServiceWidget.mockResolvedValue({
type: "testservice",
url: "http://example",
});
httpProxy.mockResolvedValueOnce([200, "application/json", { ok: true }]);
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api", index: "0" } };
const res = createMockRes();
await genericProxyHandler(req, res, (data) => ({ mapped: data.ok }));
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ mapped: true });
});
});

View File

@@ -0,0 +1,219 @@
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(), warn: vi.fn() },
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("widgets/widgets", () => ({
default: {
rpcwidget: {
api: "{url}/jsonrpc",
mappings: {
list: { endpoint: "test.method", params: [1, 2] },
},
},
missingapi: {
mappings: {
list: { endpoint: "test.method", params: [1, 2] },
},
},
},
}));
describe("utils/proxy/handlers/jsonrpc sendJsonRpcRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends a JSON-RPC request and returns the response", async () => {
const { sendJsonRpcRequest } = await import("./jsonrpc");
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 () => {
const { sendJsonRpcRequest } = await import("./jsonrpc");
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");
});
it("prefers Bearer auth when both basic credentials and a key are provided", async () => {
const { sendJsonRpcRequest } = await import("./jsonrpc");
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 [, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, {
username: "u",
password: "p",
key: "token",
});
expect(JSON.parse(data)).toEqual({ ok: true });
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer token");
});
it("maps transport/parse failures into a JSON-RPC style error response", async () => {
const { sendJsonRpcRequest } = await import("./jsonrpc");
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("not-json")]);
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: expect.any(Number), message: expect.any(String) },
});
expect(logger.debug).toHaveBeenCalled();
});
it("normalizes id=null responses so the client can still receive a result", async () => {
const { sendJsonRpcRequest } = await import("./jsonrpc");
httpProxy.mockImplementationOnce(async (_url, params) => {
const req = JSON.parse(params.body);
expect(req.id).toBe(1);
return [200, "application/json", Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: null, result: { ok: true } }))];
});
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
expect(status).toBe(200);
expect(JSON.parse(data)).toEqual({ ok: true });
});
});
describe("utils/proxy/handlers/jsonrpc proxy handler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("looks up the widget, applies mappings, and returns JSON-RPC data", async () => {
const { default: jsonrpcProxyHandler } = await import("./jsonrpc");
getServiceWidget.mockResolvedValue({ type: "rpcwidget", url: "http://rpc", key: "token" });
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: { method: req.method, params: req.params } })),
];
});
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "test.method", index: 0 } };
const res = createMockRes();
await jsonrpcProxyHandler(req, res);
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json).toEqual({ method: "test.method", params: [1, 2] });
});
it("returns 403 when the widget does not support API calls", async () => {
const { default: jsonrpcProxyHandler } = await import("./jsonrpc");
getServiceWidget.mockResolvedValue({ type: "missingapi", url: "http://rpc" });
const req = { method: "GET", query: { group: "g", service: "s", endpoint: "test.method", index: 0 } };
const res = createMockRes();
await jsonrpcProxyHandler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Service does not support API calls" });
});
it("returns 400 for invalid requests without group/service", async () => {
const { default: jsonrpcProxyHandler } = await import("./jsonrpc");
const req = { method: "GET", query: { endpoint: "test.method" } };
const res = createMockRes();
await jsonrpcProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid proxy service type" });
});
});
describe("utils/proxy/handlers/jsonrpc unexpected errors", () => {
it("returns 500 when the JSON-RPC client throws a non-JSONRPCErrorException", async () => {
vi.resetModules();
vi.doMock("json-rpc-2.0", () => {
class JSONRPCErrorException extends Error {
constructor(message, code) {
super(message);
this.code = code;
}
}
class JSONRPCClient {
constructor() {}
receive() {}
async request() {
throw new Error("boom");
}
}
return { JSONRPCClient, JSONRPCErrorException };
});
const { sendJsonRpcRequest } = await import("./jsonrpc");
const [status, , data] = await sendJsonRpcRequest("http://rpc", "test.method", null, { key: "token" });
expect(status).toBe(500);
expect(JSON.parse(data)).toEqual({ result: null, error: { code: 2, message: "Error: boom" } });
expect(logger.warn).toHaveBeenCalled();
});
});

View File

@@ -137,6 +137,9 @@ export default async function synologyProxyHandler(req, res) {
}
const serviceWidget = await getServiceWidget(group, service, index);
if (!serviceWidget) {
return res.status(400).json({ error: "Invalid proxy service type" });
}
const widget = widgets?.[serviceWidget.type];
const mapping = widget?.mappings?.[endpoint];
if (!widget.api || !mapping) {
@@ -158,7 +161,8 @@ export default async function synologyProxyHandler(req, res) {
let [status, contentType, data] = await httpProxy(url);
if (status !== 200) {
logger.debug("Error %d calling url %s", status, url);
return res.status(status, data);
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(data);
}
let json = asJson(data);

View File

@@ -0,0 +1,380 @@
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("returns 400 when group/service are missing", async () => {
const req = { query: { endpoint: "download", index: "0" } };
const res = createMockRes();
await synologyProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid proxy service type" });
});
it("returns 400 when the widget cannot be resolved", async () => {
getServiceWidget.mockResolvedValue(false);
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
const res = createMockRes();
await synologyProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid proxy service type" });
});
it("returns 403 when the endpoint is not mapped", async () => {
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
const req = { query: { group: "g", service: "svc", endpoint: "nope", index: "0" } };
const res = createMockRes();
await synologyProxyHandler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "Service does not support API calls" });
});
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("caches api info lookups to avoid repeated query calls", async () => {
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
httpProxy
// first call info query
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ data: { "SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 } } })),
])
// first call api
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))])
// second call api only (info should be cached)
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ success: true }))]);
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
const res1 = createMockRes();
const res2 = createMockRes();
await synologyProxyHandler(req, res1);
await synologyProxyHandler(req, res2);
expect(httpProxy).toHaveBeenCalledTimes(3);
// second invocation should not re-fetch api info
expect(httpProxy.mock.calls[2][0]).toContain("/webapi/entry.cgi?api=SYNO.DownloadStation2.Task");
});
it("returns non-200 proxy responses as-is (with content-type)", async () => {
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ data: { "SYNO.DownloadStation2.Task": { path: "entry.cgi", maxVersion: 2 } } })),
])
.mockResolvedValueOnce([503, "text/plain", Buffer.from("nope")]);
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
const res = createMockRes();
await synologyProxyHandler(req, res);
expect(res.headers["Content-Type"]).toBe("text/plain");
expect(res.statusCode).toBe(503);
expect(res.body).toEqual(Buffer.from("nope"));
});
it("returns 400 when the API name is unrecognized", async () => {
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ data: {} }))]);
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
const res = createMockRes();
await synologyProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Unrecognized API name: SYNO.DownloadStation2.Task" });
});
it("logs a warning when API info returns invalid JSON and treats the API name as unrecognized", async () => {
getServiceWidget.mockResolvedValue({ type: "synology", url: "http://nas", username: "u", password: "p" });
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("{not json")]);
const req = { query: { group: "g", service: "svc", endpoint: "download", index: "0" } };
const res = createMockRes();
await synologyProxyHandler(req, res);
expect(logger.warn).toHaveBeenCalled();
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Unrecognized API name: SYNO.DownloadStation2.Task" });
});
it("includes a 2FA hint when authentication fails with a 403+ error code", 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 -> triggers login
.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 returns success false with 2fa-required code
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ success: false, error: { code: 403 } })),
]);
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(expect.objectContaining({ code: 403, error: expect.stringContaining("2FA") }));
});
it("handles non-200 login responses and surfaces a synology error code", 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 -> triggers login
.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 is non-200 => login() returns early
.mockResolvedValueOnce([
503,
"application/json",
Buffer.from(JSON.stringify({ success: false, error: { code: 103 } })),
]);
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: 103, error: "The requested method does not exist." });
});
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." });
});
it.each([
[102, "The requested API does not exist."],
[104, "The requested version does not support the functionality."],
[105, "The logged in session does not have permission."],
[107, "Session interrupted by duplicated login."],
[119, "Invalid session or SID not found."],
])("maps synology error code %s to a friendly error", async (code, expected) => {
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 -> triggers login
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ success: false, error: { code } })),
])
// 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 with the same code
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(JSON.stringify({ success: false, error: { code } })),
]);
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, error: expected });
});
});

View File

@@ -249,6 +249,7 @@ export async function httpProxy(url, params = {}) {
const [status, contentType, data, responseHeaders] = await request;
return [status, contentType, data, responseHeaders, params];
} catch (err) {
const rawError = Array.isArray(err) ? err[1] : err;
logger.error(
"Error calling %s//%s%s%s...",
constructedUrl.protocol,
@@ -260,7 +261,13 @@ export async function httpProxy(url, params = {}) {
return [
500,
"application/json",
{ error: { message: err?.message ?? "Unknown error", url: sanitizeErrorURL(url), rawError: err } },
{
error: {
message: rawError?.message ?? "Unknown error",
url: sanitizeErrorURL(url),
rawError,
},
},
null,
];
}

View File

@@ -0,0 +1,423 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { state, cache, logger, dns, net, cookieJar } = vi.hoisted(() => ({
state: {
response: {
statusCode: 200,
headers: { "content-type": "application/json" },
body: Buffer.from(""),
},
error: null,
lastAgentOptions: null,
lastRequestParams: null,
lastWrittenBody: null,
},
cache: {
get: vi.fn(),
put: vi.fn(),
},
logger: {
debug: vi.fn(),
error: vi.fn(),
},
dns: {
lookup: vi.fn(),
resolve4: vi.fn(),
resolve6: vi.fn(),
},
net: {
isIP: vi.fn(),
},
cookieJar: {
addCookieToJar: vi.fn(),
setCookieHeader: vi.fn(),
},
}));
vi.mock("node:dns", () => ({
default: dns,
}));
vi.mock("node:net", () => ({
default: net,
}));
vi.mock("follow-redirects", async () => {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
function Agent(opts) {
this.opts = opts;
}
function makeRequest() {
return (url, params, cb) => {
const req = new EventEmitter();
state.lastRequestParams = params;
state.lastWrittenBody = null;
req.write = vi.fn((chunk) => {
state.lastWrittenBody = chunk;
});
req.end = vi.fn(() => {
state.lastAgentOptions = params?.agent?.opts ?? null;
if (state.error) {
req.emit("error", state.error);
return;
}
const res = new Readable({
read() {
this.push(state.response.body);
this.push(null);
},
});
res.statusCode = state.response.statusCode;
res.headers = state.response.headers;
cb(res);
});
return req;
};
}
return {
http: { request: makeRequest(), Agent },
https: { request: makeRequest(), Agent },
};
});
vi.mock("memory-cache", () => ({
default: cache,
}));
vi.mock("./cookie-jar", () => cookieJar);
vi.mock("utils/logger", () => ({
default: () => logger,
}));
describe("utils/proxy/http cachedRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
state.error = null;
state.response = {
statusCode: 200,
headers: { "content-type": "application/json" },
body: Buffer.from(""),
};
state.lastAgentOptions = null;
state.lastRequestParams = null;
state.lastWrittenBody = null;
vi.resetModules();
});
it("returns cached values without calling httpProxy", async () => {
cache.get.mockReturnValueOnce({ ok: true });
const httpMod = await import("./http");
const spy = vi.spyOn(httpMod, "httpProxy");
const data = await httpMod.cachedRequest("http://example.com");
expect(data).toEqual({ ok: true });
expect(spy).not.toHaveBeenCalled();
});
it("parses json buffer responses and caches the result", async () => {
cache.get.mockReturnValueOnce(null);
state.response.body = Buffer.from('{"a":1}');
const httpMod = await import("./http");
const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua");
expect(data).toEqual({ a: 1 });
expect(cache.put).toHaveBeenCalledWith("http://example.com/data", { a: 1 }, 1 * 1000 * 60);
});
it("falls back to string when cachedRequest cannot parse json", async () => {
cache.get.mockReturnValueOnce(null);
state.response.body = Buffer.from("not-json");
const httpMod = await import("./http");
const data = await httpMod.cachedRequest("http://example.com/data", 1, "ua");
expect(data).toBe("not-json");
expect(logger.debug).toHaveBeenCalled();
});
});
describe("utils/proxy/http homepageDNSLookupFn", () => {
const getLookupFn = async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com");
expect(state.lastAgentOptions?.lookup).toEqual(expect.any(Function));
return state.lastAgentOptions.lookup;
};
beforeEach(() => {
vi.clearAllMocks();
state.error = null;
state.lastAgentOptions = null;
net.isIP.mockReturnValue(0);
dns.lookup.mockImplementation((hostname, options, cb) => cb(null, "127.0.0.1", 4));
dns.resolve4.mockImplementation((hostname, cb) => cb(null, ["127.0.0.1"]));
dns.resolve6.mockImplementation((hostname, cb) => cb(null, ["::1"]));
vi.resetModules();
});
it("short-circuits when hostname is already an IP (all=false)", async () => {
const lookup = await getLookupFn();
net.isIP.mockReturnValueOnce(4);
const cb = vi.fn();
lookup("1.2.3.4", cb);
expect(dns.lookup).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(null, "1.2.3.4", 4);
});
it("short-circuits when hostname is already an IP (all=true)", async () => {
const lookup = await getLookupFn();
net.isIP.mockReturnValueOnce(6);
const cb = vi.fn();
lookup("::1", { all: true }, cb);
expect(dns.lookup).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(null, [{ address: "::1", family: 6 }]);
});
it("uses dns.lookup when it succeeds (2-argument form)", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(null, "10.0.0.1", 4));
lookup("example.com", cb);
expect(dns.lookup).toHaveBeenCalledWith("example.com", {}, expect.any(Function));
expect(dns.resolve4).not.toHaveBeenCalled();
expect(dns.resolve6).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(null, "10.0.0.1", 4);
});
it("does not fall back for non-ENOTFOUND/EAI_NONAME lookup errors", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const err = Object.assign(new Error("temporary"), { code: "EAI_AGAIN" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(err));
lookup("example.com", { all: true }, cb);
expect(dns.resolve4).not.toHaveBeenCalled();
expect(dns.resolve6).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(err);
});
it("falls back to resolve4 when lookup fails with ENOTFOUND and family=4", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["1.1.1.1"]));
lookup("example.com", { family: 4, all: true }, cb);
expect(dns.resolve4).toHaveBeenCalledWith("example.com", expect.any(Function));
expect(dns.resolve6).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(null, [{ address: "1.1.1.1", family: 4 }]);
expect(logger.debug).toHaveBeenCalledWith("DNS fallback to c-ares resolver succeeded for %s", "example.com");
});
it("falls back to resolve6 when lookup fails with ENOTFOUND and family=6", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["::1"]));
lookup("example.com", 6, cb);
expect(dns.lookup).toHaveBeenCalledWith("example.com", { family: 6 }, expect.any(Function));
expect(dns.resolve4).not.toHaveBeenCalled();
expect(dns.resolve6).toHaveBeenCalledWith("example.com", expect.any(Function));
expect(cb).toHaveBeenCalledWith(null, ["::1"], 6);
});
it("tries resolve4 then resolve6 when lookup fails and no family is specified", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve4.mockImplementationOnce((hostname, resolveCb) =>
resolveCb(Object.assign(new Error("v4 failed"), { code: "EAI_FAIL" })),
);
dns.resolve6.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, ["::1"]));
lookup("example.com", { all: true }, cb);
expect(dns.resolve4).toHaveBeenCalledWith("example.com", expect.any(Function));
expect(dns.resolve6).toHaveBeenCalledWith("example.com", expect.any(Function));
expect(cb).toHaveBeenCalledWith(null, [{ address: "::1", family: 6 }]);
});
it("returns ENOTFOUND when fallback resolver returns no addresses", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(null, []));
lookup("example.com", { family: 4, all: true }, cb);
const err = cb.mock.calls[0][0];
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("ENOTFOUND");
expect(dns.resolve6).not.toHaveBeenCalled();
});
it("returns resolve error when fallback resolver fails", async () => {
const lookup = await getLookupFn();
const cb = vi.fn();
const lookupErr = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
const resolveErr = Object.assign(new Error("resolver down"), { code: "EAI_FAIL" });
dns.lookup.mockImplementationOnce((hostname, options, lookupCb) => lookupCb(lookupErr));
dns.resolve4.mockImplementationOnce((hostname, resolveCb) => resolveCb(resolveErr));
lookup("example.com", { family: 4, all: true }, cb);
expect(cb).toHaveBeenCalledWith(resolveErr);
expect(logger.debug).toHaveBeenCalledWith(
"DNS fallback failed for %s: lookup error=%s, resolve error=%s",
"example.com",
"ENOTFOUND",
"EAI_FAIL",
);
});
});
describe("utils/proxy/http httpProxy", () => {
beforeEach(() => {
vi.clearAllMocks();
state.error = null;
state.response = {
statusCode: 200,
headers: { "content-type": "application/json" },
body: Buffer.from("ok"),
};
state.lastAgentOptions = null;
state.lastRequestParams = null;
state.lastWrittenBody = null;
process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = "";
vi.resetModules();
});
it("sets content-length and writes request bodies", async () => {
const httpMod = await import("./http");
const body = "abc";
const [status] = await httpMod.httpProxy("http://example.com", { method: "POST", body, headers: {} });
expect(status).toBe(200);
expect(state.lastRequestParams.headers["content-length"]).toBe(3);
expect(state.lastWrittenBody).toBe(body);
});
it("installs a beforeRedirect hook and updates the cookie jar", async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com");
expect(state.lastRequestParams.beforeRedirect).toEqual(expect.any(Function));
expect(cookieJar.setCookieHeader).toHaveBeenCalled();
expect(cookieJar.addCookieToJar).toHaveBeenCalled();
});
it("updates cookies during redirects via beforeRedirect", async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com");
state.lastRequestParams.beforeRedirect(
{ href: "http://example.com/redirect" },
{ headers: { "set-cookie": ["a=b"] } },
);
expect(cookieJar.addCookieToJar).toHaveBeenCalledWith("http://example.com/redirect", { "set-cookie": ["a=b"] });
expect(cookieJar.setCookieHeader).toHaveBeenCalledWith("http://example.com/redirect", expect.any(Object));
});
it("supports gzip-compressed responses", async () => {
const { gzipSync } = await import("node:zlib");
state.response.headers["content-encoding"] = "gzip";
state.response.body = gzipSync(Buffer.from("hello"));
const httpMod = await import("./http");
const [, , data] = await httpMod.httpProxy("http://example.com");
expect(Buffer.from(data).toString()).toBe("hello");
});
it("logs when gzip decoding emits an error", async () => {
const { PassThrough } = await import("node:stream");
vi.doMock("node:zlib", async () => {
const actual = await vi.importActual("node:zlib");
return {
...actual,
createUnzip: () => {
const pt = new PassThrough();
pt.on("pipe", () => {
queueMicrotask(() => {
pt.emit("error", new Error("bad gzip"));
pt.end();
});
});
return pt;
},
};
});
vi.resetModules();
const httpMod = await import("./http");
state.response.headers["content-encoding"] = "gzip";
state.response.body = Buffer.from("hello");
await httpMod.httpProxy("http://example.com");
expect(logger.error).toHaveBeenCalled();
vi.unmock("node:zlib");
});
it("applies strict IPv4 agent options when HOMEPAGE_PROXY_DISABLE_IPV6 is true", async () => {
process.env.HOMEPAGE_PROXY_DISABLE_IPV6 = "true";
const httpMod = await import("./http");
await httpMod.httpProxy("http://example.com");
expect(state.lastAgentOptions.family).toBe(4);
expect(state.lastAgentOptions.autoSelectFamily).toBe(false);
});
it("uses the https agent with rejectUnauthorized=false for https:// URLs", async () => {
const httpMod = await import("./http");
await httpMod.httpProxy("https://example.com");
expect(state.lastAgentOptions.rejectUnauthorized).toBe(false);
});
it("returns a sanitized error response when the request fails", async () => {
state.error = Object.assign(new Error("boom"), { code: "EHOSTUNREACH" });
const httpMod = await import("./http");
const [status, contentType, data] = await httpMod.httpProxy("http://example.com/?apikey=secret");
expect(status).toBe(500);
expect(contentType).toBe("application/json");
expect(data.error.message).toBe("boom");
expect(data.error.url).toContain("apikey=***");
});
});

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({
default: useSWR,
}));
import useWidgetAPI from "./use-widget-api";
describe("utils/proxy/use-widget-api", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("formats the proxy url and passes refreshInterval when provided in options", () => {
useSWR.mockReturnValue({ data: { ok: true }, error: undefined, mutate: "m" });
const widget = { service_group: "g", service_name: "s", index: 0 };
const result = useWidgetAPI(widget, "status", { refreshInterval: 123, foo: "bar" });
expect(useSWR).toHaveBeenCalledWith(
expect.stringContaining("/api/services/proxy?"),
expect.objectContaining({ refreshInterval: 123 }),
);
expect(result.data).toEqual({ ok: true });
expect(result.error).toBeUndefined();
expect(result.mutate).toBe("m");
});
it("returns data.error as the top-level error", () => {
const dataError = { message: "nope" };
useSWR.mockReturnValue({ data: { error: dataError }, error: undefined, mutate: vi.fn() });
const widget = { service_group: "g", service_name: "s", index: 0 };
const result = useWidgetAPI(widget, "status", {});
expect(result.error).toBe(dataError);
});
it("disables the request when endpoint is an empty string", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined, mutate: vi.fn() });
const widget = { service_group: "g", service_name: "s", index: 0 };
useWidgetAPI(widget, "");
expect(useSWR).toHaveBeenCalledWith(null, {});
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
const { loggerError } = vi.hoisted(() => ({
loggerError: vi.fn(),
}));
vi.mock("utils/logger", () => ({
default: () => ({
error: loggerError,
}),
}));
vi.mock("widgets/widgets", () => ({
default: {
test: {
mappings: {
foo: {
endpoint: "foo",
validate: ["a", "b"],
},
},
},
},
}));
import validateWidgetData from "./validate-widget-data";
describe("utils/proxy/validate-widget-data", () => {
it("returns false when buffer JSON cannot be parsed", () => {
expect(validateWidgetData({ type: "test" }, "foo", Buffer.from("not json"))).toBe(false);
expect(loggerError).toHaveBeenCalled();
});
it("retries parsing after stripping whitespace (e.g. vertical tab) and validates required keys", () => {
// JSON.parse allows only a subset of whitespace; vertical tab triggers a parse error.
const data = Buffer.from(`{\u000B"a": 1, "b": 2}`);
expect(validateWidgetData({ type: "test" }, "foo", data)).toBe(true);
});
it("returns false when required validate keys are missing", () => {
expect(validateWidgetData({ type: "test" }, "foo", Buffer.from(JSON.stringify({ a: 1 })))).toBe(false);
expect(loggerError).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import themes from "./themes";
describe("utils/styles/themes", () => {
it("contains expected theme palettes", () => {
expect(themes).toHaveProperty("slate");
expect(themes.slate).toEqual(
expect.objectContaining({
light: expect.stringMatching(/^#[0-9a-f]{6}$/i),
dark: expect.stringMatching(/^#[0-9a-f]{6}$/i),
iconStart: expect.stringMatching(/^#[0-9a-f]{6}$/i),
iconEnd: expect.stringMatching(/^#[0-9a-f]{6}$/i),
}),
);
});
});

View File

@@ -0,0 +1,15 @@
import * as Icons from "react-icons/wi";
import { describe, expect, it } from "vitest";
import mapIcon from "./condition-map";
describe("utils/weather/condition-map", () => {
it("maps known condition codes to day/night icons", () => {
expect(mapIcon(1000, "day")).toBe(Icons.WiDaySunny);
expect(mapIcon(1000, "night")).toBe(Icons.WiNightClear);
});
it("falls back to a default icon for unknown codes", () => {
expect(mapIcon(999999, "day")).toBe(Icons.WiDaySunny);
});
});

View File

@@ -0,0 +1,15 @@
import * as Icons from "react-icons/wi";
import { describe, expect, it } from "vitest";
import mapIcon from "./openmeteo-condition-map";
describe("utils/weather/openmeteo-condition-map", () => {
it("maps known condition codes to day/night icons", () => {
expect(mapIcon(95, "day")).toBe(Icons.WiDayThunderstorm);
expect(mapIcon(95, "night")).toBe(Icons.WiNightAltThunderstorm);
});
it("falls back to a default icon for unknown codes", () => {
expect(mapIcon(999999, "day")).toBe(Icons.WiDaySunny);
});
});

View File

@@ -0,0 +1,15 @@
import * as Icons from "react-icons/wi";
import { describe, expect, it } from "vitest";
import mapIcon from "./owm-condition-map";
describe("utils/weather/owm-condition-map", () => {
it("maps known condition codes to day/night icons", () => {
expect(mapIcon(804, "day")).toBe(Icons.WiCloudy);
expect(mapIcon(500, "night")).toBe(Icons.WiNightAltRain);
});
it("falls back to a default icon for unknown codes", () => {
expect(mapIcon(999999, "day")).toBe(Icons.WiDaySunny);
});
});