mirror of
https://github.com/gethomepage/homepage.git
synced 2026-02-07 16:30:52 +08:00
Chore: homepage tests (#6278)
This commit is contained in:
@@ -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) {
|
||||
|
||||
265
src/utils/config/api-response.test.js
Normal file
265
src/utils/config/api-response.test.js
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
90
src/utils/config/config.check-copy.test.js
Normal file
90
src/utils/config/config.check-copy.test.js
Normal 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" }));
|
||||
});
|
||||
});
|
||||
59
src/utils/config/config.test.js
Normal file
59
src/utils/config/config.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import cache from "memory-cache";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("utils/config/config", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
cache.del("homepageEnvironmentVariables");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
cache.del("homepageEnvironmentVariables");
|
||||
});
|
||||
|
||||
it("substituteEnvironmentVars replaces HOMEPAGE_VAR_* placeholders", async () => {
|
||||
process.env.HOMEPAGE_VAR_FOO = "bar";
|
||||
|
||||
const mod = await import("./config");
|
||||
expect(mod.substituteEnvironmentVars("x {{HOMEPAGE_VAR_FOO}} y")).toBe("x bar y");
|
||||
});
|
||||
|
||||
it("substituteEnvironmentVars replaces HOMEPAGE_FILE_* placeholders with file contents", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "homepage-config-test-"));
|
||||
const secretPath = path.join(dir, "secret.txt");
|
||||
writeFileSync(secretPath, "secret", "utf8");
|
||||
|
||||
process.env.HOMEPAGE_FILE_SECRET = secretPath;
|
||||
|
||||
const mod = await import("./config");
|
||||
expect(mod.substituteEnvironmentVars("token={{HOMEPAGE_FILE_SECRET}}")).toBe("token=secret");
|
||||
});
|
||||
|
||||
it("getSettings reads from HOMEPAGE_CONFIG_DIR and converts layout list to an object", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "homepage-settings-test-"));
|
||||
process.env.HOMEPAGE_CONFIG_DIR = dir;
|
||||
process.env.HOMEPAGE_VAR_TITLE = "MyTitle";
|
||||
|
||||
// Create a minimal settings.yaml; checkAndCopyConfig will see it exists and won't copy skeleton.
|
||||
writeFileSync(
|
||||
path.join(dir, "settings.yaml"),
|
||||
['title: "{{HOMEPAGE_VAR_TITLE}}"', "layout:", " - GroupA:", " style: row"].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.resetModules(); // ensure CONF_DIR is computed from updated env
|
||||
const mod = await import("./config");
|
||||
|
||||
const settings = mod.getSettings();
|
||||
expect(settings.title).toBe("MyTitle");
|
||||
expect(settings.layout).toEqual({ GroupA: { style: "row" } });
|
||||
});
|
||||
});
|
||||
@@ -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]) {
|
||||
|
||||
109
src/utils/config/docker.test.js
Normal file
109
src/utils/config/docker.test.js
Normal 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" });
|
||||
});
|
||||
});
|
||||
108
src/utils/config/kubernetes.test.js
Normal file
108
src/utils/config/kubernetes.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
41
src/utils/config/proxmox.test.js
Normal file
41
src/utils/config/proxmox.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
587
src/utils/config/service-helpers.test.js
Normal file
587
src/utils/config/service-helpers.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
30
src/utils/config/shvl.test.js
Normal file
30
src/utils/config/shvl.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
88
src/utils/config/widget-helpers.test.js
Normal file
88
src/utils/config/widget-helpers.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
53
src/utils/contexts/color.test.jsx
Normal file
53
src/utils/contexts/color.test.jsx
Normal 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));
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
|
||||
33
src/utils/contexts/settings.test.jsx
Normal file
33
src/utils/contexts/settings.test.jsx
Normal 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}');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
|
||||
33
src/utils/contexts/tab.test.jsx
Normal file
33
src/utils/contexts/tab.test.jsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
64
src/utils/contexts/theme.test.jsx
Normal file
64
src/utils/contexts/theme.test.jsx
Normal 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"));
|
||||
});
|
||||
});
|
||||
191
src/utils/highlights.test.js
Normal file
191
src/utils/highlights.test.js
Normal 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" });
|
||||
});
|
||||
});
|
||||
27
src/utils/hooks/window-focus.test.jsx
Normal file
27
src/utils/hooks/window-focus.test.jsx
Normal 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"));
|
||||
});
|
||||
});
|
||||
28
src/utils/kubernetes/export.test.js
Normal file
28
src/utils/kubernetes/export.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
106
src/utils/kubernetes/httproute-list.test.js
Normal file
106
src/utils/kubernetes/httproute-list.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ export default async function listIngress() {
|
||||
logger.debug(error);
|
||||
return null;
|
||||
});
|
||||
ingressList = ingressData.items;
|
||||
ingressList = ingressData?.items ?? [];
|
||||
}
|
||||
return ingressList;
|
||||
}
|
||||
|
||||
79
src/utils/kubernetes/ingress-list.test.js
Normal file
79
src/utils/kubernetes/ingress-list.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
199
src/utils/kubernetes/resource-helpers.test.js
Normal file
199
src/utils/kubernetes/resource-helpers.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
111
src/utils/kubernetes/traefik-list.test.js
Normal file
111
src/utils/kubernetes/traefik-list.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
24
src/utils/kubernetes/utils.test.js
Normal file
24
src/utils/kubernetes/utils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
12
src/utils/layout/columns.test.js
Normal file
12
src/utils/layout/columns.test.js
Normal 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
182
src/utils/logger.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
92
src/utils/proxy/api-helpers.test.js
Normal file
92
src/utils/proxy/api-helpers.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
45
src/utils/proxy/cookie-jar.test.js
Normal file
45
src/utils/proxy/cookie-jar.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
404
src/utils/proxy/handlers/credentialed.test.js
Normal file
404
src/utils/proxy/handlers/credentialed.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
256
src/utils/proxy/handlers/generic.test.js
Normal file
256
src/utils/proxy/handlers/generic.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
219
src/utils/proxy/handlers/jsonrpc.test.js
Normal file
219
src/utils/proxy/handlers/jsonrpc.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
380
src/utils/proxy/handlers/synology.test.js
Normal file
380
src/utils/proxy/handlers/synology.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
423
src/utils/proxy/http.test.js
Normal file
423
src/utils/proxy/http.test.js
Normal 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=***");
|
||||
});
|
||||
});
|
||||
49
src/utils/proxy/use-widget-api.test.js
Normal file
49
src/utils/proxy/use-widget-api.test.js
Normal 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, {});
|
||||
});
|
||||
});
|
||||
44
src/utils/proxy/validate-widget-data.test.js
Normal file
44
src/utils/proxy/validate-widget-data.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
17
src/utils/styles/themes.test.js
Normal file
17
src/utils/styles/themes.test.js
Normal 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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
15
src/utils/weather/condition-map.test.js
Normal file
15
src/utils/weather/condition-map.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
15
src/utils/weather/openmeteo-condition-map.test.js
Normal file
15
src/utils/weather/openmeteo-condition-map.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
15
src/utils/weather/owm-condition-map.test.js
Normal file
15
src/utils/weather/owm-condition-map.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user