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,356 @@
|
||||
/**
|
||||
* contributes.settings validation.
|
||||
*
|
||||
* A settings field is the one thing in a manifest that core writes to the
|
||||
* database on a plugin's behalf, so the schema has to reject anything it would
|
||||
* not know how to store or render.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseManifest,
|
||||
validateManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
} from "../../plugins/manifest.js";
|
||||
|
||||
function base(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "sample-plugin",
|
||||
name: "Sample Plugin",
|
||||
version: "1.0.0",
|
||||
description: "A fixture.",
|
||||
author: { name: "Termix Tests" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
|
||||
capabilities: ["kv:own"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withSettings(settings: unknown) {
|
||||
return base({ contributes: { settings } });
|
||||
}
|
||||
|
||||
describe("contributes.settings", () => {
|
||||
it("accepts a manifest with every field type", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||
{ key: "baseUrl", type: "string", labelKey: "k", default: "" },
|
||||
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||
{ key: "enabled", type: "boolean", labelKey: "k" },
|
||||
{
|
||||
key: "mode",
|
||||
type: "select",
|
||||
labelKey: "k",
|
||||
options: [{ value: "a", labelKey: "k" }],
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
labelKey: "k",
|
||||
options: [{ value: "a", labelKey: "k" }],
|
||||
},
|
||||
{ key: "notes", type: "textarea", labelKey: "k" },
|
||||
{ key: "raw", type: "json", labelKey: "k" },
|
||||
{ key: "browser", type: "custom", component: "devices" },
|
||||
],
|
||||
user: [{ key: "theme", type: "string", labelKey: "k" }],
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a manifest with no settings at all", () => {
|
||||
expect(validateManifest(base())).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unknown field property", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", wat: 1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain('Unknown field "wat"');
|
||||
});
|
||||
|
||||
it("rejects an unknown scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({ global: [{ key: "a", type: "string", labelKey: "k" }] }),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain('Unknown field "global"');
|
||||
});
|
||||
|
||||
it("rejects an unknown field type", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "colour", labelKey: "k" }] }),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must be one of");
|
||||
});
|
||||
|
||||
it("rejects a duplicate key within one scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "string", labelKey: "k" },
|
||||
{ key: "a", type: "number", labelKey: "k" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain('duplicates "a"');
|
||||
});
|
||||
|
||||
it("allows the same key in two different scopes", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "shared", type: "string", labelKey: "k" }],
|
||||
user: [{ key: "shared", type: "string", labelKey: "k" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires labelKey for everything but a custom field", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "string" }] }),
|
||||
).join(" "),
|
||||
).toContain("labelKey");
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "custom", component: "x" }] }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires component on a custom field and forbids it elsewhere", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type: "custom" }] }),
|
||||
).join(" "),
|
||||
).toContain("component");
|
||||
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", component: "x" }],
|
||||
}),
|
||||
).join(" "),
|
||||
).toContain('only valid when type is "custom"');
|
||||
});
|
||||
|
||||
it("requires non-empty options on select and multiselect", () => {
|
||||
for (const type of ["select", "multiselect"]) {
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({ admin: [{ key: "a", type, labelKey: "k" }] }),
|
||||
).join(" "),
|
||||
).toContain("options must be a non-empty array");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects options on a field that has no options", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{
|
||||
key: "a",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
options: [{ value: "x", labelKey: "k" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("only valid for select and multiselect");
|
||||
});
|
||||
|
||||
it("rejects min greater than max", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "number", labelKey: "k", min: 10, max: 1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must not be greater than");
|
||||
});
|
||||
|
||||
it("rejects min or max on a non-number field", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a", type: "string", labelKey: "k", min: 1 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("only valid for number fields");
|
||||
});
|
||||
|
||||
it("rejects requires that names no boolean in the same scope", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "string", labelKey: "k", requires: "missing" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must name a boolean field");
|
||||
});
|
||||
|
||||
it("rejects requires that names a non-boolean field", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "flag", type: "string", labelKey: "k" },
|
||||
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("must name a boolean field");
|
||||
});
|
||||
|
||||
it("accepts requires pointing at a boolean declared later", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [
|
||||
{ key: "a", type: "string", labelKey: "k", requires: "flag" },
|
||||
{ key: "flag", type: "boolean", labelKey: "k" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets a host field require the section's enable key", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [
|
||||
{
|
||||
key: "port",
|
||||
type: "number",
|
||||
labelKey: "k",
|
||||
requires: "enableThing",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a host field colliding with the enable key", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "enableThing", type: "boolean", labelKey: "k" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("already the section's enableKey");
|
||||
});
|
||||
|
||||
it("requires enableLabelKey alongside enableKey", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
host: { enableKey: "enableThing", fields: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("enableLabelKey");
|
||||
});
|
||||
|
||||
it("rejects a key that would not be index-safe", () => {
|
||||
const errors = validateManifest(
|
||||
withSettings({
|
||||
admin: [{ key: "a".repeat(80), type: "string", labelKey: "k" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain("short alphanumeric key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings field permissions", () => {
|
||||
it("rejects a short permission the plugin does not declare", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
settings: {
|
||||
user: [
|
||||
{ key: "a", type: "string", labelKey: "k", permission: "use" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors.join(" ")).toContain(
|
||||
"not declared in contributes.permissions",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a short permission the plugin declares", () => {
|
||||
const { errors, manifest } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
permissions: [{ name: "use", titleKey: "k", descriptionKey: "k" }],
|
||||
settings: {
|
||||
user: [
|
||||
{ key: "a", type: "string", labelKey: "k", permission: "use" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.contributes?.settings?.user?.[0].permission).toBe("use");
|
||||
});
|
||||
|
||||
it("accepts a fully qualified permission from another namespace", () => {
|
||||
// Cross-namespace ids cannot be checked here: the validator has no idea
|
||||
// which other plugins exist. Taken as given, exactly like ctx.rbac does.
|
||||
const { errors } = parseManifest(
|
||||
base({
|
||||
contributes: {
|
||||
settings: {
|
||||
user: [
|
||||
{
|
||||
key: "a",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
permission: "hosts.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* The /plugins/:id/settings routes.
|
||||
*
|
||||
* These decide who may change what, so the tests are mostly about refusal: a
|
||||
* non-admin must not write install-wide configuration, a user must not write
|
||||
* another user's settings or a host they cannot edit, and a secret must never
|
||||
* come back over the wire.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
permissions: new Set<string>(),
|
||||
hostEditAccess: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
return { pluginLogger: logger, databaseLogger: logger, apiLogger: logger };
|
||||
});
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
state.permissions.has(permission),
|
||||
requirePermission:
|
||||
(permission: string) =>
|
||||
(
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
if (!state.permissions.has(permission)) {
|
||||
res.status(403).json({ error: "Insufficient permissions" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
canAccessHost: async () => ({
|
||||
hasAccess: state.hostEditAccess,
|
||||
isOwner: state.hostEditAccess,
|
||||
isShared: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => {}),
|
||||
getAuditUsername: vi.fn(async (id: string) => id),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const MANIFEST: PluginManifest = {
|
||||
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: {
|
||||
permissions: [{ name: "tweak", titleKey: "k", descriptionKey: "k" }],
|
||||
settings: {
|
||||
admin: [
|
||||
{ key: "apiKey", type: "secret", labelKey: "k" },
|
||||
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
|
||||
{
|
||||
key: "narrowed",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
permission: "tweak",
|
||||
},
|
||||
],
|
||||
user: [
|
||||
{ key: "theme", type: "string", labelKey: "k", default: "dark" },
|
||||
{
|
||||
key: "advanced",
|
||||
type: "string",
|
||||
labelKey: "k",
|
||||
permission: "tweak",
|
||||
},
|
||||
],
|
||||
host: {
|
||||
enableKey: "enableThing",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "port", type: "number", labelKey: "k" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as PluginManifest;
|
||||
|
||||
vi.mock("../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: {
|
||||
get: (id: string) =>
|
||||
id === "sample"
|
||||
? { id, manifest: MANIFEST, state: "active" }
|
||||
: undefined,
|
||||
list: () => [{ id: "sample", manifest: MANIFEST, state: "active" }],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
interface Row {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const rows: Row[] = [];
|
||||
|
||||
function find(scope: string, scopeId: string | null, key: string) {
|
||||
return rows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
id === "sample"
|
||||
? { id, manifestJson: JSON.stringify(MANIFEST), state: "enabled" }
|
||||
: null,
|
||||
listAll: async () => [],
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async () => [],
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
rows.filter((row) => row.scope === scope && row.scopeId === scopeId),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
existing.encrypted = encrypted;
|
||||
return;
|
||||
}
|
||||
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
}));
|
||||
|
||||
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 pluginRoutes = (await import("../../database/routes/plugins.js")).default;
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rows.length = 0;
|
||||
state.userId = "user-1";
|
||||
state.permissions = new Set<string>();
|
||||
state.hostEditAccess = true;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/plugins", pluginRoutes);
|
||||
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function call(path: string, init?: RequestInit) {
|
||||
return fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
describe("admin scope", () => {
|
||||
it("refuses a reader without admin.plugins.manage", async () => {
|
||||
const response = await call("/plugins/sample/settings/admin");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("refuses a writer without admin.plugins.manage", async () => {
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ retries: 2 }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets an admin read and write", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const write = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ retries: 3 }),
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call("/plugins/sample/settings/admin");
|
||||
expect((await read.json()).values.retries).toBe(3);
|
||||
});
|
||||
|
||||
it("reports a rejected value per field", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ retries: 99 }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).errors.retries).toContain("at most 5");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses a key the manifest never declared", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ somethingElse: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).errors.somethingElse).toBeDefined();
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never returns a secret's value", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: "tskey-very-secret" }),
|
||||
});
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin");
|
||||
const body = await response.text();
|
||||
|
||||
expect(body).not.toContain("tskey-very-secret");
|
||||
expect(JSON.parse(body).values.apiKey).toEqual({ set: true });
|
||||
});
|
||||
|
||||
it("treats a redacted echo as no change", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: "tskey-original" }),
|
||||
});
|
||||
await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: { set: true }, retries: 1 }),
|
||||
});
|
||||
|
||||
const row = rows.find((entry) => entry.key === "apiKey")!;
|
||||
expect(
|
||||
Buffer.from(
|
||||
JSON.parse(row.value!).slice("sysenc:v1:".length),
|
||||
"base64",
|
||||
).toString("utf8"),
|
||||
).toBe("tskey-original");
|
||||
});
|
||||
|
||||
it("keeps the admin gate even for a field naming its own permission", async () => {
|
||||
// Holding the field's permission is not enough: admin scope is admin
|
||||
// scope, so a plugin cannot hand install-wide config to an ordinary user.
|
||||
state.permissions.add("sample.tweak");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ narrowed: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("narrows an admin field to holders of its permission", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const denied = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ narrowed: "x" }),
|
||||
});
|
||||
expect(denied.status).toBe(400);
|
||||
expect((await denied.json()).errors.narrowed).toContain("permission");
|
||||
|
||||
state.permissions.add("sample.tweak");
|
||||
const allowed = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ narrowed: "x" }),
|
||||
});
|
||||
expect(allowed.status).toBe(200);
|
||||
});
|
||||
|
||||
it("404s for a plugin that does not exist", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/nope/settings/admin");
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a body that is not an object", async () => {
|
||||
state.permissions.add("admin.plugins.manage");
|
||||
|
||||
const response = await call("/plugins/sample/settings/admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify([1, 2, 3]),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user scope", () => {
|
||||
it("lets any authenticated user read and write their own", async () => {
|
||||
const write = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ theme: "light" }),
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call("/plugins/sample/settings/user");
|
||||
expect((await read.json()).values.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("scopes writes to the caller, never to a user id in the body", async () => {
|
||||
await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ theme: "light", userId: "someone-else" }),
|
||||
});
|
||||
|
||||
// The bogus key is rejected and the row belongs to the caller.
|
||||
expect(rows.every((row) => row.scopeId === "user-1")).toBe(true);
|
||||
|
||||
state.userId = "user-2";
|
||||
const other = await call("/plugins/sample/settings/user");
|
||||
expect((await other.json()).values.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("refuses a user field gated on a permission the caller lacks", async () => {
|
||||
const response = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ advanced: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).errors.advanced).toContain("permission");
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows it once the caller holds that permission", async () => {
|
||||
state.permissions.add("sample.tweak");
|
||||
|
||||
const response = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ advanced: "x" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses an admin field through the user route", async () => {
|
||||
const response = await call("/plugins/sample/settings/user", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apiKey: "nope" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host scope", () => {
|
||||
it("lets a user with edit access read and write", async () => {
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enableThing: true, port: 22 }),
|
||||
});
|
||||
expect(write.status).toBe(200);
|
||||
|
||||
const read = await call("/plugins/sample/settings/host/7");
|
||||
const values = (await read.json()).values;
|
||||
expect(values.enableThing).toBe(true);
|
||||
expect(values.port).toBe(22);
|
||||
});
|
||||
|
||||
it("refuses a host the caller cannot edit", async () => {
|
||||
state.hostEditAccess = false;
|
||||
|
||||
const read = await call("/plugins/sample/settings/host/7");
|
||||
expect(read.status).toBe(403);
|
||||
|
||||
const write = await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ port: 22 }),
|
||||
});
|
||||
expect(write.status).toBe(403);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects an invalid host id", async () => {
|
||||
const response = await call("/plugins/sample/settings/host/not-a-number");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("keeps each host's values apart", async () => {
|
||||
await call("/plugins/sample/settings/host/7", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ port: 22 }),
|
||||
});
|
||||
await call("/plugins/sample/settings/host/8", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ port: 2222 }),
|
||||
});
|
||||
|
||||
const seven = await (await call("/plugins/sample/settings/host/7")).json();
|
||||
const eight = await (await call("/plugins/sample/settings/host/8")).json();
|
||||
|
||||
expect(seven.values.port).toBe(22);
|
||||
expect(eight.values.port).toBe(2222);
|
||||
});
|
||||
|
||||
it("returns the declared default for an untouched host", async () => {
|
||||
const response = await call("/plugins/sample/settings/host/9");
|
||||
|
||||
expect((await response.json()).values.enableThing).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user