mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
/**
|
|
* Settings: a secret is encrypted at rest and never read back, and every
|
|
* write path goes through the same checks, including the host import path
|
|
* that used to write rows straight into plugin_settings.
|
|
*
|
|
* The HTTP scope checks (admin needs admin.plugins.manage, a user only ever
|
|
* writes their own, a host needs edit access) are covered route by route in
|
|
* settings-routes.test.ts.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
interface Row {
|
|
pluginId: string;
|
|
scope: string;
|
|
scopeId: string | null;
|
|
key: string;
|
|
value: string | null;
|
|
encrypted: boolean;
|
|
}
|
|
|
|
const state = vi.hoisted(() => ({
|
|
rows: [] as Row[],
|
|
manifests: [] as unknown[],
|
|
normalizer: null as null | ((raw: Record<string, unknown>) => unknown),
|
|
}));
|
|
|
|
vi.mock("../../../utils/logger.js", () => {
|
|
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
return { sshLogger: log, pluginLogger: log };
|
|
});
|
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
|
createCurrentPluginSettingsRepository: () => ({
|
|
get: async (p: string, s: string, id: string | null, k: string) =>
|
|
state.rows.find(
|
|
(row) =>
|
|
row.pluginId === p &&
|
|
row.scope === s &&
|
|
row.scopeId === id &&
|
|
row.key === k,
|
|
) ?? null,
|
|
getAll: async (p: string, s: string, id: string | null) =>
|
|
state.rows.filter(
|
|
(row) => row.pluginId === p && row.scope === s && row.scopeId === id,
|
|
),
|
|
set: async (
|
|
pluginId: string,
|
|
scope: string,
|
|
scopeId: string | null,
|
|
key: string,
|
|
value: string | null,
|
|
encrypted = false,
|
|
) => {
|
|
state.rows = state.rows.filter(
|
|
(row) =>
|
|
!(
|
|
row.pluginId === pluginId &&
|
|
row.scope === scope &&
|
|
row.scopeId === scopeId &&
|
|
row.key === key
|
|
),
|
|
);
|
|
state.rows.push({ pluginId, scope, scopeId, key, value, encrypted });
|
|
},
|
|
}),
|
|
}));
|
|
vi.mock("../../../utils/system-secret-crypto.js", () => ({
|
|
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
|
|
encryptSystemSecret: async (plaintext: string) =>
|
|
`sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
|
|
decryptSystemSecret: async (stored: string) =>
|
|
Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString("utf8"),
|
|
}));
|
|
vi.mock("../../../plugins/index.js", () => ({
|
|
getPluginRuntime: () => ({
|
|
loader: {
|
|
list: () =>
|
|
state.manifests.map((manifest) => ({ state: "active", manifest })),
|
|
},
|
|
}),
|
|
}));
|
|
vi.mock("../../../plugins/registry.js", () => ({
|
|
consume: () => state.normalizer,
|
|
}));
|
|
|
|
const { applyPluginHostImportSettings } =
|
|
await import("../../../database/routes/host-plugin-settings.js");
|
|
const { getAllSettings, setSetting } =
|
|
await import("../../../plugins/settings.js");
|
|
|
|
const MANIFEST = {
|
|
id: "vaultish",
|
|
name: "Vaultish",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
contributes: {
|
|
settings: {
|
|
host: {
|
|
fields: [
|
|
{ key: "token", type: "secret", labelKey: "k" },
|
|
{ key: "port", type: "number", labelKey: "k", min: 1, max: 65535 },
|
|
],
|
|
},
|
|
admin: [{ key: "apiKey", type: "secret", labelKey: "k" }],
|
|
},
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
state.rows = [];
|
|
state.manifests = [MANIFEST];
|
|
state.normalizer = null;
|
|
});
|
|
|
|
describe("secrets at rest", () => {
|
|
it("are stored encrypted and read back only as { set }", async () => {
|
|
await setSetting(MANIFEST as never, "admin", null, "apiKey", "hunter2");
|
|
const row = state.rows.find((entry) => entry.key === "apiKey")!;
|
|
expect(row.encrypted).toBe(true);
|
|
expect(row.value).not.toContain("hunter2");
|
|
|
|
const shown = await getAllSettings(MANIFEST as never, "admin", null, {
|
|
redactSecrets: true,
|
|
});
|
|
expect(shown.apiKey).toEqual({ set: true });
|
|
});
|
|
});
|
|
|
|
describe("the host import path", () => {
|
|
it("encrypts a secret a plugin's normalizer returns", async () => {
|
|
state.normalizer = () => ({ token: "from-import", port: 8200 });
|
|
await applyPluginHostImportSettings(7, {});
|
|
|
|
const token = state.rows.find((entry) => entry.key === "token")!;
|
|
expect(token.encrypted).toBe(true);
|
|
expect(token.value).not.toContain("from-import");
|
|
expect(state.rows.find((entry) => entry.key === "port")?.value).toBe(
|
|
"8200",
|
|
);
|
|
});
|
|
|
|
it("refuses a key the manifest does not declare, and a bad value", async () => {
|
|
state.normalizer = () => ({ injected: "x", port: 999999 });
|
|
await applyPluginHostImportSettings(7, {});
|
|
expect(state.rows).toEqual([]);
|
|
});
|
|
|
|
it("never restores a secret carried in an export", async () => {
|
|
await applyPluginHostImportSettings(7, {
|
|
pluginSettings: { vaultish: { token: "leaked", port: 22 } },
|
|
});
|
|
expect(state.rows.map((entry) => entry.key)).toEqual(["port"]);
|
|
});
|
|
});
|