mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
/**
|
|
* The plugin settings service.
|
|
*
|
|
* Storage is faked at the repository boundary; everything above it - schema
|
|
* lookup, validation, encryption, redaction and change notification - is the
|
|
* real implementation, because that is where the behaviour worth protecting
|
|
* lives.
|
|
*/
|
|
|
|
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[] = [];
|
|
|
|
function find(
|
|
pluginId: string,
|
|
scope: string,
|
|
scopeId: string | null,
|
|
key: string,
|
|
) {
|
|
return rows.find(
|
|
(row) =>
|
|
row.pluginId === pluginId &&
|
|
row.scope === scope &&
|
|
row.scopeId === scopeId &&
|
|
row.key === key,
|
|
);
|
|
}
|
|
|
|
const settingsRepository = {
|
|
get: vi.fn(
|
|
async (
|
|
pluginId: string,
|
|
scope: string,
|
|
scopeId: string | null,
|
|
key: string,
|
|
) => find(pluginId, scope, scopeId, key) ?? null,
|
|
),
|
|
getAll: vi.fn(
|
|
async (pluginId: string, scope: string, scopeId: string | null) =>
|
|
rows.filter(
|
|
(row) =>
|
|
row.pluginId === pluginId &&
|
|
row.scope === scope &&
|
|
row.scopeId === scopeId,
|
|
),
|
|
),
|
|
set: vi.fn(
|
|
async (
|
|
pluginId: string,
|
|
scope: string,
|
|
scopeId: string | null,
|
|
key: string,
|
|
value: string | null,
|
|
encrypted = false,
|
|
) => {
|
|
const existing = find(pluginId, scope, scopeId, key);
|
|
if (existing) {
|
|
existing.value = value;
|
|
existing.encrypted = encrypted;
|
|
return;
|
|
}
|
|
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
|
},
|
|
),
|
|
};
|
|
|
|
const coreSettings = new Map<string, string>();
|
|
|
|
vi.mock("../../database/repositories/factory.js", () => ({
|
|
createCurrentPluginSettingsRepository: () => settingsRepository,
|
|
createCurrentSettingsRepository: () => ({
|
|
get: async (key: string) => coreSettings.get(key) ?? null,
|
|
}),
|
|
}));
|
|
|
|
// Real AES would need the system key on disk. The prefix and the idempotence
|
|
// are what the service depends on, so the double keeps both.
|
|
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
|
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
|
encryptSystemSecret: async (plaintext: string) =>
|
|
!plaintext || plaintext.startsWith("sysenc:v1:")
|
|
? plaintext
|
|
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
|
decryptSystemSecret: async (stored: string) =>
|
|
stored.startsWith("sysenc:v1:")
|
|
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
|
|
"utf8",
|
|
)
|
|
: stored,
|
|
}));
|
|
|
|
const {
|
|
CORE_SETTINGS_ALLOWLIST,
|
|
clearSettingsListeners,
|
|
declaredFields,
|
|
findField,
|
|
getAllSettings,
|
|
getSetting,
|
|
onSettingsChange,
|
|
readCoreSetting,
|
|
setSetting,
|
|
} = await import("../../plugins/settings.js");
|
|
|
|
function manifest(settings: unknown): PluginManifest {
|
|
return {
|
|
id: "sample",
|
|
name: "Sample",
|
|
version: "1.0.0",
|
|
description: "",
|
|
author: { name: "t" },
|
|
license: "MIT",
|
|
category: "Productivity",
|
|
engine: { termix: ">=2.9.0", api: "1" },
|
|
capabilities: [],
|
|
contributes: { settings },
|
|
} as PluginManifest;
|
|
}
|
|
|
|
const ADMIN = manifest({
|
|
admin: [
|
|
{ key: "apiKey", type: "secret", labelKey: "k" },
|
|
{ key: "baseUrl", type: "string", labelKey: "k", default: "https://x" },
|
|
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
|
{ key: "enabled", type: "boolean", labelKey: "k", default: false },
|
|
{
|
|
key: "mode",
|
|
type: "select",
|
|
labelKey: "k",
|
|
options: [
|
|
{ value: "fast", labelKey: "k" },
|
|
{ value: "slow", labelKey: "k" },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
beforeEach(() => {
|
|
rows.length = 0;
|
|
coreSettings.clear();
|
|
clearSettingsListeners("sample");
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("declaredFields", () => {
|
|
it("returns the fields for each scope", () => {
|
|
expect(declaredFields(ADMIN, "admin").map((f) => f.key)).toEqual([
|
|
"apiKey",
|
|
"baseUrl",
|
|
"retries",
|
|
"enabled",
|
|
"mode",
|
|
]);
|
|
expect(declaredFields(ADMIN, "user")).toEqual([]);
|
|
});
|
|
|
|
it("treats the host enable key as a real boolean field", () => {
|
|
const withHost = manifest({
|
|
host: {
|
|
enableKey: "enableThing",
|
|
enableLabelKey: "k",
|
|
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
|
},
|
|
});
|
|
|
|
const fields = declaredFields(withHost, "host");
|
|
expect(fields.map((f) => f.key)).toEqual(["enableThing", "port"]);
|
|
expect(fields[0]).toMatchObject({ type: "boolean", default: false });
|
|
});
|
|
|
|
it("finds a field by key", () => {
|
|
expect(findField(ADMIN, "admin", "baseUrl")?.type).toBe("string");
|
|
expect(findField(ADMIN, "admin", "nope")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("setSetting", () => {
|
|
it("writes a valid value and reads it back", async () => {
|
|
expect(
|
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y"),
|
|
).toBeNull();
|
|
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://y");
|
|
});
|
|
|
|
it("refuses a key the manifest never declared", async () => {
|
|
const error = await setSetting(ADMIN, "admin", null, "wat", "x");
|
|
|
|
expect(error).toContain("not a settings field this plugin declares");
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it("refuses a value that fails the declared constraint", async () => {
|
|
expect(await setSetting(ADMIN, "admin", null, "retries", 99)).toContain(
|
|
"at most 5",
|
|
);
|
|
expect(
|
|
await setSetting(ADMIN, "admin", null, "mode", "sideways"),
|
|
).toContain("must be one of");
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it("coerces the strings a form actually sends", async () => {
|
|
expect(await setSetting(ADMIN, "admin", null, "retries", "3")).toBeNull();
|
|
expect(await getSetting(ADMIN, "admin", null, "retries")).toBe(3);
|
|
|
|
expect(
|
|
await setSetting(ADMIN, "admin", null, "enabled", "true"),
|
|
).toBeNull();
|
|
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(true);
|
|
});
|
|
|
|
it("keeps each scope separate", async () => {
|
|
const scoped = manifest({
|
|
admin: [{ key: "v", type: "string", labelKey: "k" }],
|
|
user: [{ key: "v", type: "string", labelKey: "k" }],
|
|
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
|
|
});
|
|
|
|
await setSetting(scoped, "admin", null, "v", "a");
|
|
await setSetting(scoped, "user", "user-1", "v", "u");
|
|
await setSetting(scoped, "host", 7, "v", "h");
|
|
|
|
expect(await getSetting(scoped, "admin", null, "v")).toBe("a");
|
|
expect(await getSetting(scoped, "user", "user-1", "v")).toBe("u");
|
|
expect(await getSetting(scoped, "host", 7, "v")).toBe("h");
|
|
// A different user sees nothing of the first user's value.
|
|
expect(await getSetting(scoped, "user", "user-2", "v")).toBeUndefined();
|
|
});
|
|
|
|
it("stores a host id as text, matching the polymorphic scope column", async () => {
|
|
const scoped = manifest({
|
|
host: { fields: [{ key: "v", type: "string", labelKey: "k" }] },
|
|
});
|
|
|
|
await setSetting(scoped, "host", 12, "v", "x");
|
|
|
|
expect(rows[0].scopeId).toBe("12");
|
|
// Either spelling resolves to the same row.
|
|
expect(await getSetting(scoped, "host", "12", "v")).toBe("x");
|
|
});
|
|
});
|
|
|
|
describe("defaults", () => {
|
|
it("falls back to the declared default when nothing is stored", async () => {
|
|
expect(await getSetting(ADMIN, "admin", null, "baseUrl")).toBe("https://x");
|
|
expect(await getSetting(ADMIN, "admin", null, "enabled")).toBe(false);
|
|
});
|
|
|
|
it("returns undefined for a field with no default and no value", async () => {
|
|
expect(await getSetting(ADMIN, "admin", null, "retries")).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined for a key the manifest does not declare", async () => {
|
|
expect(await getSetting(ADMIN, "admin", null, "wat")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("secrets", () => {
|
|
it("encrypts on the way in and decrypts on the way out", async () => {
|
|
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
|
|
|
const row = rows.find((entry) => entry.key === "apiKey")!;
|
|
expect(row.encrypted).toBe(true);
|
|
expect(row.value).not.toContain("tskey-secret");
|
|
expect(JSON.parse(row.value!)).toMatch(/^sysenc:v1:/);
|
|
|
|
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe(
|
|
"tskey-secret",
|
|
);
|
|
});
|
|
|
|
it("redacts a secret rather than returning it", async () => {
|
|
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
|
|
|
const values = await getAllSettings(ADMIN, "admin", null, {
|
|
redactSecrets: true,
|
|
});
|
|
|
|
expect(values.apiKey).toEqual({ set: true });
|
|
expect(JSON.stringify(values)).not.toContain("tskey-secret");
|
|
});
|
|
|
|
it("reports an unset secret as set:false", async () => {
|
|
const values = await getAllSettings(ADMIN, "admin", null, {
|
|
redactSecrets: true,
|
|
});
|
|
|
|
expect(values.apiKey).toEqual({ set: false });
|
|
});
|
|
|
|
it("treats a redacted echo as leave-it-alone", async () => {
|
|
await setSetting(ADMIN, "admin", null, "apiKey", "original");
|
|
|
|
expect(
|
|
await setSetting(ADMIN, "admin", null, "apiKey", { set: true }),
|
|
).toBeNull();
|
|
|
|
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("original");
|
|
});
|
|
|
|
it("lets a secret be replaced and cleared", async () => {
|
|
await setSetting(ADMIN, "admin", null, "apiKey", "first");
|
|
await setSetting(ADMIN, "admin", null, "apiKey", "second");
|
|
expect(await getSetting(ADMIN, "admin", null, "apiKey")).toBe("second");
|
|
|
|
await setSetting(ADMIN, "admin", null, "apiKey", "");
|
|
const values = await getAllSettings(ADMIN, "admin", null, {
|
|
redactSecrets: true,
|
|
});
|
|
expect(values.apiKey).toEqual({ set: false });
|
|
});
|
|
|
|
it("hands a plugin its own secret in the clear", async () => {
|
|
await setSetting(ADMIN, "admin", null, "apiKey", "tskey-secret");
|
|
|
|
const values = await getAllSettings(ADMIN, "admin", null);
|
|
|
|
expect(values.apiKey).toBe("tskey-secret");
|
|
});
|
|
});
|
|
|
|
describe("getAllSettings", () => {
|
|
it("is driven by the manifest, not by the stored rows", async () => {
|
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://y");
|
|
// A row for a field the plugin has since dropped.
|
|
rows.push({
|
|
pluginId: "sample",
|
|
scope: "admin",
|
|
scopeId: null,
|
|
key: "removedField",
|
|
value: JSON.stringify("stale"),
|
|
encrypted: false,
|
|
});
|
|
|
|
const values = await getAllSettings(ADMIN, "admin", null);
|
|
|
|
expect(values.baseUrl).toBe("https://y");
|
|
expect(values.enabled).toBe(false);
|
|
expect(values).not.toHaveProperty("removedField");
|
|
});
|
|
|
|
it("returns nothing for a scope the plugin declares no fields in", async () => {
|
|
expect(await getAllSettings(ADMIN, "user", "user-1")).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("onSettingsChange", () => {
|
|
it("fires on a successful write", async () => {
|
|
const listener = vi.fn();
|
|
onSettingsChange("sample", "baseUrl", listener);
|
|
|
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
|
|
|
expect(listener).toHaveBeenCalledWith("https://z");
|
|
});
|
|
|
|
it("does not fire for a rejected write", async () => {
|
|
const listener = vi.fn();
|
|
onSettingsChange("sample", "retries", listener);
|
|
|
|
await setSetting(ADMIN, "admin", null, "retries", 99);
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stops firing once unsubscribed", async () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = onSettingsChange("sample", "baseUrl", listener);
|
|
|
|
unsubscribe();
|
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("only notifies listeners for that key and plugin", async () => {
|
|
const other = vi.fn();
|
|
const another = vi.fn();
|
|
onSettingsChange("sample", "enabled", other);
|
|
onSettingsChange("different-plugin", "baseUrl", another);
|
|
|
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
|
|
|
expect(other).not.toHaveBeenCalled();
|
|
expect(another).not.toHaveBeenCalled();
|
|
clearSettingsListeners("different-plugin");
|
|
});
|
|
|
|
it("survives a listener that throws", async () => {
|
|
const good = vi.fn();
|
|
onSettingsChange("sample", "baseUrl", () => {
|
|
throw new Error("listener blew up");
|
|
});
|
|
onSettingsChange("sample", "baseUrl", good);
|
|
|
|
await expect(
|
|
setSetting(ADMIN, "admin", null, "baseUrl", "https://z"),
|
|
).resolves.toBeNull();
|
|
expect(good).toHaveBeenCalled();
|
|
});
|
|
|
|
it("drops every listener when the plugin deactivates", async () => {
|
|
const listener = vi.fn();
|
|
onSettingsChange("sample", "baseUrl", listener);
|
|
|
|
clearSettingsListeners("sample");
|
|
await setSetting(ADMIN, "admin", null, "baseUrl", "https://z");
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("readCoreSetting", () => {
|
|
it("returns an allowlisted key", async () => {
|
|
coreSettings.set("app_name", "Termix");
|
|
|
|
expect(await readCoreSetting("app_name")).toBe("Termix");
|
|
});
|
|
|
|
it("refuses a key that is not on the allowlist", async () => {
|
|
coreSettings.set("jwt_secret", "nope");
|
|
|
|
await expect(readCoreSetting("jwt_secret")).rejects.toThrow(
|
|
/not readable by plugins/,
|
|
);
|
|
});
|
|
|
|
it("does not expose anything sensitive through the allowlist", () => {
|
|
for (const key of CORE_SETTINGS_ALLOWLIST) {
|
|
expect(key).not.toMatch(/secret|password|token|key$/i);
|
|
}
|
|
});
|
|
});
|