mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
/**
|
|
* The pluginSettings map attached to host responses.
|
|
*
|
|
* The host editor needs these with the host, not one request per plugin per
|
|
* host, so the shape and the query count both matter.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
|
|
|
interface Row {
|
|
pluginId: string;
|
|
scope: string;
|
|
scopeId: string | null;
|
|
key: string;
|
|
value: string | null;
|
|
encrypted: boolean;
|
|
}
|
|
|
|
const rows: Row[] = [];
|
|
const loaded: { id: string; manifest: PluginManifest; state: string }[] = [];
|
|
const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) =>
|
|
rows.filter(
|
|
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
|
|
),
|
|
);
|
|
const setCalls: Array<[string, string, string, string, string]> = [];
|
|
const set = vi.fn(
|
|
async (
|
|
pluginId: string,
|
|
scope: string,
|
|
scopeId: string,
|
|
key: string,
|
|
value: string,
|
|
) => {
|
|
setCalls.push([pluginId, scope, scopeId, key, value]);
|
|
},
|
|
);
|
|
|
|
vi.mock("../../../utils/logger.js", () => {
|
|
const logger = {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
};
|
|
return { sshLogger: logger, pluginLogger: logger, databaseLogger: logger };
|
|
});
|
|
|
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
|
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
|
|
}));
|
|
|
|
vi.mock("../../../plugins/index.js", () => ({
|
|
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
|
|
}));
|
|
|
|
vi.mock("../../../utils/system-secret-crypto.js", () => ({
|
|
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
|
encryptSystemSecret: async (plaintext: string) => plaintext,
|
|
decryptSystemSecret: async (stored: string) => stored,
|
|
}));
|
|
|
|
const registryProviders = new Map<string, unknown>();
|
|
vi.mock("../../../plugins/registry.js", () => ({
|
|
consume: (key: string) => registryProviders.get(key),
|
|
}));
|
|
|
|
const {
|
|
attachHostPluginSettings,
|
|
loadHostPluginSettings,
|
|
withHostPluginSettings,
|
|
writeHostPluginSettings,
|
|
applyPluginHostImportSettings,
|
|
} = await import("../../../database/routes/host-plugin-settings.js");
|
|
|
|
function manifest(id: string, host: unknown): PluginManifest {
|
|
return {
|
|
id,
|
|
name: id,
|
|
version: "1.0.0",
|
|
description: "",
|
|
author: { name: "t" },
|
|
license: "MIT",
|
|
category: "Productivity",
|
|
engine: { termix: ">=2.9.0", api: "1" },
|
|
capabilities: [],
|
|
contributes: { settings: { host } },
|
|
} as PluginManifest;
|
|
}
|
|
|
|
const DOCKER = manifest("docker", {
|
|
enableKey: "enableDocker",
|
|
enableLabelKey: "k",
|
|
fields: [{ key: "socketPath", type: "string", labelKey: "k" }],
|
|
});
|
|
|
|
beforeEach(() => {
|
|
rows.length = 0;
|
|
loaded.length = 0;
|
|
setCalls.length = 0;
|
|
registryProviders.clear();
|
|
getAllForScopeIds.mockClear();
|
|
set.mockClear();
|
|
});
|
|
|
|
describe("loadHostPluginSettings", () => {
|
|
it("returns nothing when no plugin declares host settings", async () => {
|
|
loaded.push({
|
|
id: "other",
|
|
manifest: manifest("other", undefined),
|
|
state: "active",
|
|
});
|
|
|
|
const result = await loadHostPluginSettings([1, 2]);
|
|
|
|
expect(result.size).toBe(0);
|
|
expect(getAllForScopeIds).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores a plugin that is not running", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "disabled" });
|
|
|
|
const result = await loadHostPluginSettings([1]);
|
|
|
|
expect(result.size).toBe(0);
|
|
});
|
|
|
|
it("uses one query for the whole list", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
|
|
await loadHostPluginSettings([1, 2, 3, 4, 5]);
|
|
|
|
expect(getAllForScopeIds).toHaveBeenCalledTimes(1);
|
|
expect(getAllForScopeIds).toHaveBeenCalledWith("host", [
|
|
"1",
|
|
"2",
|
|
"3",
|
|
"4",
|
|
"5",
|
|
]);
|
|
});
|
|
|
|
it("merges stored values over declared defaults", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
rows.push({
|
|
pluginId: "docker",
|
|
scope: "host",
|
|
scopeId: "1",
|
|
key: "enableDocker",
|
|
value: JSON.stringify(true),
|
|
encrypted: false,
|
|
});
|
|
|
|
const result = await loadHostPluginSettings([1, 2]);
|
|
|
|
expect(result.get(1)).toEqual({
|
|
docker: { enableDocker: true, socketPath: undefined },
|
|
});
|
|
// Host 2 has nothing stored, so it falls back to the declared default.
|
|
expect(result.get(2)).toEqual({
|
|
docker: { enableDocker: false, socketPath: undefined },
|
|
});
|
|
});
|
|
|
|
it("keeps one host's values out of another's", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
rows.push({
|
|
pluginId: "docker",
|
|
scope: "host",
|
|
scopeId: "1",
|
|
key: "socketPath",
|
|
value: JSON.stringify("/var/run/docker.sock"),
|
|
encrypted: false,
|
|
});
|
|
|
|
const result = await loadHostPluginSettings([1, 2]);
|
|
|
|
expect(result.get(1)!.docker.socketPath).toBe("/var/run/docker.sock");
|
|
expect(result.get(2)!.docker.socketPath).toBeUndefined();
|
|
});
|
|
|
|
it("redacts a host-scope secret", async () => {
|
|
loaded.push({
|
|
id: "vault",
|
|
manifest: manifest("vault", {
|
|
fields: [{ key: "token", type: "secret", labelKey: "k" }],
|
|
}),
|
|
state: "active",
|
|
});
|
|
rows.push({
|
|
pluginId: "vault",
|
|
scope: "host",
|
|
scopeId: "1",
|
|
key: "token",
|
|
value: JSON.stringify("s.verysecret"),
|
|
encrypted: true,
|
|
});
|
|
|
|
const result = await loadHostPluginSettings([1]);
|
|
|
|
expect(result.get(1)!.vault.token).toEqual({ set: true });
|
|
expect(JSON.stringify(result.get(1))).not.toContain("verysecret");
|
|
});
|
|
|
|
it("survives a failed settings read rather than failing the host list", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
getAllForScopeIds.mockRejectedValueOnce(new Error("database is away"));
|
|
|
|
await expect(loadHostPluginSettings([1])).resolves.toEqual(new Map());
|
|
});
|
|
|
|
it("does nothing for an empty host list", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
|
|
expect((await loadHostPluginSettings([])).size).toBe(0);
|
|
expect(getAllForScopeIds).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("attachHostPluginSettings", () => {
|
|
it("attaches values to the matching host", () => {
|
|
const hosts = [{ id: 1 }, { id: 2 }] as Record<string, unknown>[];
|
|
const settings = new Map([[1, { docker: { enableDocker: true } }]]);
|
|
|
|
attachHostPluginSettings(hosts, settings);
|
|
|
|
expect(hosts[0].pluginSettings).toEqual({ docker: { enableDocker: true } });
|
|
expect(hosts[1].pluginSettings).toBeUndefined();
|
|
});
|
|
|
|
it("leaves hosts alone when there is nothing to attach", () => {
|
|
const hosts = [{ id: 1 }] as Record<string, unknown>[];
|
|
|
|
attachHostPluginSettings(hosts, new Map());
|
|
|
|
expect(hosts[0]).toEqual({ id: 1 });
|
|
});
|
|
});
|
|
|
|
describe("withHostPluginSettings", () => {
|
|
it("adds the map to a single host", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
|
|
const result = await withHostPluginSettings({ id: 1, name: "web" });
|
|
|
|
expect(result.name).toBe("web");
|
|
expect(result.pluginSettings).toEqual({
|
|
docker: { enableDocker: false, socketPath: undefined },
|
|
});
|
|
});
|
|
|
|
it("returns the host unchanged when nothing contributes", async () => {
|
|
const host = { id: 1, name: "web" };
|
|
|
|
expect(await withHostPluginSettings(host)).toBe(host);
|
|
});
|
|
|
|
it("returns the host unchanged when its id is not a number", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
const host = { name: "web" };
|
|
|
|
expect(await withHostPluginSettings(host)).toBe(host);
|
|
});
|
|
});
|
|
|
|
describe("writeHostPluginSettings", () => {
|
|
it("JSON-stringifies each value under the plugin's own namespace", async () => {
|
|
await writeHostPluginSettings("docker", 7, {
|
|
enableDocker: true,
|
|
socketPath: null,
|
|
});
|
|
|
|
expect(setCalls).toEqual([
|
|
["docker", "host", "7", "enableDocker", "true"],
|
|
["docker", "host", "7", "socketPath", "null"],
|
|
]);
|
|
});
|
|
|
|
it("skips a field whose value is undefined", async () => {
|
|
await writeHostPluginSettings("docker", 7, {
|
|
enableDocker: true,
|
|
socketPath: undefined,
|
|
});
|
|
|
|
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
|
|
});
|
|
});
|
|
|
|
describe("applyPluginHostImportSettings", () => {
|
|
it("runs every enabled plugin's registered normalizer and writes what it returns", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
loaded.push({
|
|
id: "web-endpoint",
|
|
manifest: manifest("web-endpoint", {
|
|
enableKey: "enableWebUi",
|
|
enableLabelKey: "k",
|
|
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
|
|
}),
|
|
state: "active",
|
|
});
|
|
registryProviders.set("docker.hostImportNormalizer", () => ({
|
|
enableDocker: true,
|
|
}));
|
|
registryProviders.set("web-endpoint.hostImportNormalizer", () => null);
|
|
|
|
await applyPluginHostImportSettings(7, { enableDocker: true });
|
|
|
|
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
|
|
});
|
|
|
|
it("skips a plugin with no registered normalizer", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
|
|
await applyPluginHostImportSettings(7, {});
|
|
|
|
expect(setCalls).toEqual([]);
|
|
});
|
|
|
|
it("logs and continues past a normalizer that throws", async () => {
|
|
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
|
loaded.push({
|
|
id: "web-endpoint",
|
|
manifest: manifest("web-endpoint", {
|
|
enableKey: "enableWebUi",
|
|
enableLabelKey: "k",
|
|
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
|
|
}),
|
|
state: "active",
|
|
});
|
|
registryProviders.set("docker.hostImportNormalizer", () => {
|
|
throw new Error("boom");
|
|
});
|
|
registryProviders.set("web-endpoint.hostImportNormalizer", () => ({
|
|
enableWebUi: true,
|
|
}));
|
|
|
|
await expect(applyPluginHostImportSettings(7, {})).resolves.toBeUndefined();
|
|
expect(setCalls).toEqual([
|
|
["web-endpoint", "host", "7", "enableWebUi", "true"],
|
|
]);
|
|
});
|
|
});
|