mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: give plugins their own settings and one settings screen
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user