feat: give plugins their own settings and one settings screen

This commit is contained in:
LukeGus committed 2026-09-22 18:31:19 -05:00
1 parent cc15559ea1
commit 49f0286a14
66 files changed
+36497 -451

No files matched your search

@@ -0,0 +1,147 @@
/**
* plugin_settings cleanup when a user or host goes.
*
* scope_id is polymorphic, so it carries no foreign key and the engine will
* not cascade it. The repositories delete these rows explicitly, and these
* tests exist so a later refactor that drops those calls fails here rather
* than leaving orphaned settings behind forever.
*/
import { asc } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./repositories/test-support.js";
import { HostRepository } from "../../database/repositories/host-repository.js";
import { UserRepository } from "../../database/repositories/user-repository.js";
import type { DatabaseContext } from "../../database/repositories/database-context.js";
import { pluginSettings } from "../../database/db/schema.js";
let adapter: TestSqliteDatabase | null = null;
let ctx: DatabaseContext | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
ctx = null;
}
});
async function seed(): Promise<DatabaseContext> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
ctx = context;
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES
(7, 'user-1', 'seven', '10.0.0.7', 22, 'root', 'password'),
(8, 'user-1', 'eight', '10.0.0.8', 22, 'root', 'password'),
(9, 'user-2', 'nine', '10.0.0.9', 22, 'root', 'password');
INSERT INTO plugins (id, name, version, manifest_json)
VALUES ('sample', 'Sample', '1.0.0', '{}');
`);
return context;
}
async function addSetting(
scope: string,
scopeId: string | null,
key = "k",
): Promise<void> {
await adapter!.exec(
`INSERT INTO plugin_settings (plugin_id, scope, scope_id, key, value)
VALUES ('sample', '${scope}', ${scopeId === null ? "NULL" : `'${scopeId}'`}, '${key}', '"v"')`,
);
}
async function remaining(): Promise<
{ scope: string; scopeId: string | null }[]
> {
const rows = await ctx!.drizzle
.select({
scope: pluginSettings.scope,
scopeId: pluginSettings.scopeId,
})
.from(pluginSettings)
.orderBy(asc(pluginSettings.scope), asc(pluginSettings.scopeId));
return rows;
}
describe("user deletion", () => {
it("removes that user's plugin settings", async () => {
const context = await seed();
await addSetting("user", "user-1");
await new UserRepository(context).delete("user-1");
expect(await remaining()).toEqual([]);
});
it("leaves another user's settings alone", async () => {
const context = await seed();
await addSetting("user", "user-1");
await addSetting("user", "user-2");
await new UserRepository(context).delete("user-1");
expect(await remaining()).toEqual([{ scope: "user", scopeId: "user-2" }]);
});
it("leaves admin-scope settings alone", async () => {
const context = await seed();
await addSetting("admin", null);
await addSetting("user", "user-1");
await new UserRepository(context).delete("user-1");
expect(await remaining()).toEqual([{ scope: "admin", scopeId: null }]);
});
});
describe("host deletion", () => {
it("removes that host's plugin settings", async () => {
const context = await seed();
await addSetting("host", "7");
await new HostRepository(context).deleteForUser("user-1", 7);
expect(await remaining()).toEqual([]);
});
it("leaves another host's settings alone", async () => {
const context = await seed();
await addSetting("host", "7");
await addSetting("host", "8");
await new HostRepository(context).deleteForUser("user-1", 7);
expect(await remaining()).toEqual([{ scope: "host", scopeId: "8" }]);
});
it("clears settings for every host in the bulk delete path", async () => {
const context = await seed();
await addSetting("host", "7");
await addSetting("host", "8");
await addSetting("host", "9");
await new HostRepository(context).deleteByUserId("user-1");
// Host 9 belongs to another user and keeps its settings.
expect(await remaining()).toEqual([{ scope: "host", scopeId: "9" }]);
});
it("does not confuse a host id with a user id", async () => {
const context = await seed();
await addSetting("host", "7");
await addSetting("user", "7");
await new HostRepository(context).deleteForUser("user-1", 7);
expect(await remaining()).toEqual([{ scope: "user", scopeId: "7" }]);
});
});
@@ -0,0 +1,244 @@
/**
* 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 ?? ""),
),
);
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 }),
}));
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 {
attachHostPluginSettings,
loadHostPluginSettings,
withHostPluginSettings,
} = 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;
getAllForScopeIds.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);
});
});