/** * 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(); 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[]; 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[]; 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"], ]); }); });