mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
1087 lines
35 KiB
TypeScript
1087 lines
35 KiB
TypeScript
/**
|
|
* Install, update, uninstall and switching, against a real PluginLoader and
|
|
* fixture plugins on disk. The database and the registry are faked.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import * as tar from "tar";
|
|
import { createFixturePlugin } from "./fixture-plugin.js";
|
|
import type { LoadedPlugin } from "../../plugins/loader.js";
|
|
|
|
interface Row {
|
|
id: string;
|
|
name: string;
|
|
version: string;
|
|
tier: string;
|
|
source: string;
|
|
state: string;
|
|
lastError: string | null;
|
|
registryId: string | null;
|
|
autoUpdate: boolean;
|
|
pinnedVersion: string | null;
|
|
manifestJson: string;
|
|
}
|
|
|
|
const db = vi.hoisted(() => ({
|
|
rows: new Map<string, Record<string, unknown>>(),
|
|
settings: new Map<string, string>(),
|
|
grants: [] as Array<{ pluginId: string; capability: string }>,
|
|
}));
|
|
|
|
const registry = vi.hoisted(() => ({
|
|
index: { registry: "official", name: "", plugins: [] as unknown[] },
|
|
/** Indexes of the other registries, by id. Missing means unreachable. */
|
|
others: {} as Record<string, Record<string, unknown>>,
|
|
download: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../database/repositories/factory.js", () => ({
|
|
createCurrentPluginRepository: () => ({
|
|
listAll: async () => [...db.rows.values()],
|
|
findById: async (id: string) => db.rows.get(id) ?? null,
|
|
create: async (input: Record<string, unknown>) => {
|
|
const row = {
|
|
registryId: null,
|
|
lastError: null,
|
|
autoUpdate: false,
|
|
pinnedVersion: null,
|
|
...input,
|
|
};
|
|
db.rows.set(input.id as string, row);
|
|
return row;
|
|
},
|
|
update: async (id: string, input: Record<string, unknown>) => {
|
|
const row = db.rows.get(id);
|
|
if (!row) return null;
|
|
for (const [key, value] of Object.entries(input)) {
|
|
if (value !== undefined) row[key] = value;
|
|
}
|
|
return row;
|
|
},
|
|
delete: async (id: string) => db.rows.delete(id),
|
|
}),
|
|
createCurrentSettingsRepository: () => ({
|
|
get: async (key: string) => db.settings.get(key) ?? null,
|
|
set: async (key: string, value: string) => void db.settings.set(key, value),
|
|
}),
|
|
createCurrentPluginPermissionGrantRepository: () => ({
|
|
listByPlugin: async (id: string) =>
|
|
db.grants.filter((g) => g.pluginId === id),
|
|
grant: async (input: { pluginId: string; capability: string }) =>
|
|
void db.grants.push(input),
|
|
}),
|
|
createCurrentPluginStorageRepository: () => ({
|
|
get: async () => null,
|
|
set: async () => {},
|
|
delete: async () => false,
|
|
listKeys: async () => [],
|
|
countKeys: async () => 0,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../plugins/data.js", () => ({
|
|
migratePlugin: async () => [],
|
|
removePluginData: async () => ({ tables: [], kvKeys: 0, migrations: 0 }),
|
|
describePluginTables: async () => [],
|
|
registerTable: () => ({}),
|
|
forgetTables: () => {},
|
|
}));
|
|
|
|
vi.mock("../../plugins/permissions.js", () => ({
|
|
invalidatePluginPermissionCache: () => {},
|
|
}));
|
|
|
|
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
|
return {
|
|
...actual,
|
|
fetchRegistryIndex: async (source?: { id: string }) => {
|
|
if (!source || source.id === "official") {
|
|
return actual.parseRegistryIndex(registry.index);
|
|
}
|
|
const other = registry.others[source.id];
|
|
if (!other) throw new Error(`${source.id} is offline`);
|
|
return actual.parseRegistryIndex(other);
|
|
},
|
|
downloadRelease: registry.download,
|
|
};
|
|
});
|
|
|
|
vi.mock("../../plugins/index.js", async () => {
|
|
const { PluginLoader } = await import("../../plugins/loader.js");
|
|
const loader = new PluginLoader();
|
|
const seed = async (plugins: LoadedPlugin[]) => {
|
|
for (const plugin of plugins) {
|
|
if (db.rows.has(plugin.id)) {
|
|
Object.assign(db.rows.get(plugin.id)!, {
|
|
version: plugin.manifest.version,
|
|
manifestJson: JSON.stringify(plugin.manifest),
|
|
});
|
|
continue;
|
|
}
|
|
db.rows.set(plugin.id, {
|
|
id: plugin.id,
|
|
name: plugin.manifest.name,
|
|
version: plugin.manifest.version,
|
|
tier: plugin.source === "bundled" ? "bundled" : "community",
|
|
source: plugin.source,
|
|
state: plugin.source === "bundled" ? "enabled" : "disabled",
|
|
lastError: null,
|
|
registryId: null,
|
|
autoUpdate: false,
|
|
pinnedVersion: null,
|
|
manifestJson: JSON.stringify(plugin.manifest),
|
|
} satisfies Row);
|
|
}
|
|
};
|
|
return {
|
|
getPluginRuntime: () => ({ loader }),
|
|
activatePlugin: (id: string) => loader.activate(id),
|
|
deactivatePlugin: (id: string) => loader.deactivate(id),
|
|
registerLoadedPlugins: seed,
|
|
unloadPlugin: async (id: string) => {
|
|
if (!loader.get(id)) return;
|
|
await loader.deactivate(id);
|
|
loader.forget(id);
|
|
},
|
|
};
|
|
});
|
|
|
|
const { getPluginRuntime } = await import("../../plugins/index.js");
|
|
const manage = await import("../../plugins/manage.js");
|
|
|
|
const roots: string[] = [];
|
|
let bundled: string;
|
|
let data: string;
|
|
|
|
function tempRoot(prefix: string): string {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
roots.push(root);
|
|
return root;
|
|
}
|
|
|
|
const DEFAULT_CAPS = ["hosts:read", "kv:own"];
|
|
|
|
function registryEntry(
|
|
id: string,
|
|
versions: Array<{ version: string; capabilities?: string[] }>,
|
|
prereleases: Array<{ version: string; capabilities?: string[] }> = [],
|
|
) {
|
|
const toEntry = (v: { version: string; capabilities?: string[] }) => ({
|
|
version: v.version,
|
|
api: "1",
|
|
url: `https://example.com/${id}-${v.version}.tmxplug`,
|
|
sha256: "a".repeat(64),
|
|
signature: "sig",
|
|
size: 1,
|
|
capabilities: v.capabilities ?? ["hosts:read", "kv:own"],
|
|
});
|
|
return {
|
|
id,
|
|
name: id,
|
|
versions: versions.map(toEntry),
|
|
prereleases: prereleases.map(toEntry),
|
|
};
|
|
}
|
|
|
|
/** A .tmxplug of a fixture plugin, standing in for a verified download. */
|
|
async function stageArtifact(
|
|
id: string,
|
|
manifestOverrides: Record<string, unknown> = {},
|
|
) {
|
|
const build = tempRoot("termix-build-");
|
|
createFixturePlugin({ id, root: build, manifestOverrides });
|
|
const staging = path.join(data, "plugins", ".downloads");
|
|
fs.mkdirSync(staging, { recursive: true });
|
|
const file = path.join(staging, `${id}-staged.tmxplug`);
|
|
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, ["."]);
|
|
return { file, signatureFile: `${file}.sig` };
|
|
}
|
|
|
|
async function boot() {
|
|
const { loader } = getPluginRuntime();
|
|
await manage.applyStoredPluginChoices(loader);
|
|
const loaded = await loader.loadAll();
|
|
const { registerLoadedPlugins } = await import("../../plugins/index.js");
|
|
await registerLoadedPlugins(loaded);
|
|
for (const plugin of loaded) {
|
|
if (db.rows.get(plugin.id)?.state === "enabled") {
|
|
await loader.activate(plugin.id);
|
|
}
|
|
}
|
|
return loader;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
bundled = tempRoot("termix-bundled-");
|
|
data = tempRoot("termix-data-");
|
|
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
|
process.env.DATA_DIR = data;
|
|
db.rows.clear();
|
|
db.settings.clear();
|
|
db.grants.length = 0;
|
|
registry.index.plugins = [];
|
|
registry.others = {};
|
|
registry.download.mockReset();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const { loader } = getPluginRuntime();
|
|
await loader.shutdown();
|
|
for (const plugin of loader.list()) loader.forget(plugin.id);
|
|
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
|
delete process.env.DATA_DIR;
|
|
while (roots.length) {
|
|
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("uninstall and reinstall of a bundled plugin", () => {
|
|
it("deletes it, keeps it gone after a restart, and downloads it again", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
|
let loader = await boot();
|
|
expect(loader.get("docker")?.state).toBe("active");
|
|
|
|
await manage.uninstallPlugin("docker");
|
|
expect(loader.get("docker")).toBeUndefined();
|
|
expect(db.rows.has("docker")).toBe(false);
|
|
expect(fs.existsSync(path.join(bundled, "docker"))).toBe(false);
|
|
expect(JSON.parse(db.settings.get("plugins_uninstalled")!)).toEqual([
|
|
"docker",
|
|
]);
|
|
|
|
await loader.shutdown();
|
|
for (const plugin of loader.list()) loader.forget(plugin.id);
|
|
// A recreated container brings the image's copy back; it stays ignored.
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
loader = await boot();
|
|
expect(loader.get("docker")).toBeUndefined();
|
|
|
|
registry.download.mockImplementation(() => stageArtifact("docker"));
|
|
const result = await manage.installPlugin("docker", {
|
|
userId: "admin",
|
|
capabilities: DEFAULT_CAPS,
|
|
});
|
|
expect(result.state).toBe("active");
|
|
expect(registry.download).toHaveBeenCalledOnce();
|
|
expect(loader.get("docker")?.source).toBe("user");
|
|
expect(fs.existsSync(path.join(data, "plugins", "docker.tmxplug"))).toBe(
|
|
true,
|
|
);
|
|
expect(db.rows.get("docker")).toMatchObject({
|
|
registryId: "official",
|
|
tier: "official",
|
|
});
|
|
expect(db.grants.map((g) => g.capability)).toEqual([
|
|
"hosts:read",
|
|
"kv:own",
|
|
]);
|
|
|
|
await loader.shutdown();
|
|
for (const plugin of loader.list()) loader.forget(plugin.id);
|
|
loader = await boot();
|
|
expect(loader.get("docker")?.source).toBe("user");
|
|
});
|
|
|
|
it("deletes a plugin dropped in as a plain folder", async () => {
|
|
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
|
|
const loader = await boot();
|
|
expect(loader.get("notes")?.source).toBe("user");
|
|
|
|
await manage.uninstallPlugin("notes");
|
|
expect(fs.existsSync(path.join(data, "plugins", "notes"))).toBe(false);
|
|
expect(fs.existsSync(path.join(data, "plugins"))).toBe(true);
|
|
expect(db.settings.get("plugins_uninstalled")).toBeUndefined();
|
|
});
|
|
|
|
it("refuses to install something already installed", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
|
await boot();
|
|
await expect(
|
|
manage.installPlugin("docker", { userId: "admin" }),
|
|
).rejects.toMatchObject({ code: "ALREADY_INSTALLED" });
|
|
});
|
|
});
|
|
|
|
describe("installing from the registry", () => {
|
|
it("puts the artifact in place, grants what it declares and starts it", async () => {
|
|
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
|
registry.download.mockImplementation(() =>
|
|
stageArtifact("notes", { capabilities: ["kv:own"] }),
|
|
);
|
|
const loader = await boot();
|
|
|
|
const result = await manage.installPlugin("notes", {
|
|
userId: "admin",
|
|
capabilities: DEFAULT_CAPS,
|
|
});
|
|
|
|
expect(result).toMatchObject({ id: "notes", state: "active" });
|
|
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
|
true,
|
|
);
|
|
expect(loader.get("notes")?.source).toBe("user");
|
|
expect(db.rows.get("notes")?.tier).toBe("official");
|
|
expect(db.grants).toEqual([
|
|
expect.objectContaining({ pluginId: "notes", capability: "kv:own" }),
|
|
]);
|
|
});
|
|
|
|
it("refuses a plugin that is not in the registry", async () => {
|
|
await boot();
|
|
await expect(
|
|
manage.installPlugin("nope", { userId: "admin" }),
|
|
).rejects.toMatchObject({ code: "NOT_IN_REGISTRY" });
|
|
});
|
|
|
|
it("pins an explicitly picked older version", async () => {
|
|
registry.index.plugins = [
|
|
registryEntry("notes", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
|
];
|
|
registry.download.mockImplementation(() => stageArtifact("notes"));
|
|
await boot();
|
|
|
|
await manage.installPlugin("notes", {
|
|
userId: "admin",
|
|
version: "1.0.0",
|
|
capabilities: DEFAULT_CAPS,
|
|
});
|
|
expect(db.rows.get("notes")?.pinnedVersion).toBe("1.0.0");
|
|
});
|
|
|
|
it("uninstalls a registry plugin and its files", async () => {
|
|
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
|
registry.download.mockImplementation(() => stageArtifact("notes"));
|
|
await boot();
|
|
await manage.installPlugin("notes", {
|
|
userId: "admin",
|
|
capabilities: DEFAULT_CAPS,
|
|
});
|
|
|
|
await manage.uninstallPlugin("notes");
|
|
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
|
false,
|
|
);
|
|
expect(db.rows.has("notes")).toBe(false);
|
|
expect(db.settings.get("plugins_uninstalled")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("updates", () => {
|
|
it("waits for consent when a release asks for new capabilities", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
registry.index.plugins = [
|
|
registryEntry("docker", [
|
|
{
|
|
version: "1.1.0",
|
|
capabilities: ["hosts:read", "kv:own", "notify:send"],
|
|
},
|
|
{ version: "1.0.0" },
|
|
]),
|
|
];
|
|
await boot();
|
|
|
|
await expect(
|
|
manage.updatePlugin("docker", { userId: "admin" }),
|
|
).rejects.toMatchObject({
|
|
code: "CAPABILITIES_ADDED",
|
|
details: { capabilities: ["notify:send"] },
|
|
});
|
|
expect(registry.download).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lists an update and skips it in update-all when pinned", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
registry.index.plugins = [
|
|
registryEntry("docker", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
|
];
|
|
await boot();
|
|
|
|
const listing = await manage.listRegistry();
|
|
expect(listing.plugins[0]).toMatchObject({
|
|
installed: true,
|
|
installedVersion: "1.0.0",
|
|
latestVersion: "1.1.0",
|
|
updateAvailable: true,
|
|
addedCapabilities: [],
|
|
bundled: true,
|
|
});
|
|
|
|
await manage.setPluginOptions("docker", { pinned: true });
|
|
const result = await manage.updateAllPlugins({ userId: "admin" });
|
|
expect(result.updated).toEqual([]);
|
|
expect(registry.download).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("beta channel", () => {
|
|
it("offers betas only to a plugin on the beta channel", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
registry.index.plugins = [
|
|
registryEntry(
|
|
"docker",
|
|
[{ version: "1.0.0" }],
|
|
[{ version: "1.1.0-beta.1" }],
|
|
),
|
|
];
|
|
await boot();
|
|
|
|
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
|
|
channel: "stable",
|
|
latestVersion: "1.0.0",
|
|
latestBeta: "1.1.0-beta.1",
|
|
updateAvailable: false,
|
|
});
|
|
expect(
|
|
(await manage.listRegistry()).plugins[0].versions.map((v) => [
|
|
v.version,
|
|
v.prerelease,
|
|
]),
|
|
).toEqual([
|
|
["1.1.0-beta.1", true],
|
|
["1.0.0", false],
|
|
]);
|
|
|
|
await manage.setPluginOptions("docker", { channel: "beta" });
|
|
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
|
|
channel: "beta",
|
|
latestVersion: "1.1.0-beta.1",
|
|
updateAvailable: true,
|
|
});
|
|
});
|
|
|
|
it("keeps an installed beta after going back to stable", async () => {
|
|
createFixturePlugin({
|
|
id: "docker",
|
|
root: bundled,
|
|
manifestOverrides: { version: "1.1.0-beta.1" },
|
|
});
|
|
registry.index.plugins = [
|
|
registryEntry(
|
|
"docker",
|
|
[{ version: "1.0.0" }],
|
|
[{ version: "1.1.0-beta.1" }],
|
|
),
|
|
];
|
|
await boot();
|
|
|
|
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
|
|
channel: "stable",
|
|
installedVersion: "1.1.0-beta.1",
|
|
updateAvailable: false,
|
|
});
|
|
const result = await manage.updateAllPlugins({ userId: "admin" });
|
|
expect(result.updated).toEqual([]);
|
|
expect(registry.download).not.toHaveBeenCalled();
|
|
|
|
registry.index.plugins = [
|
|
registryEntry("docker", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
|
];
|
|
expect((await manage.listRegistry()).plugins[0]).toMatchObject({
|
|
latestVersion: "1.1.0",
|
|
updateAvailable: true,
|
|
});
|
|
});
|
|
|
|
it("installs the newest beta when asked to follow betas", async () => {
|
|
registry.index.plugins = [
|
|
registryEntry(
|
|
"notes",
|
|
[{ version: "1.0.0" }],
|
|
[{ version: "1.1.0-beta.2" }],
|
|
),
|
|
];
|
|
registry.download.mockImplementation(() =>
|
|
stageArtifact("notes", { version: "1.1.0-beta.2" }),
|
|
);
|
|
await boot();
|
|
|
|
const result = await manage.installPlugin("notes", {
|
|
userId: "admin",
|
|
channel: "beta",
|
|
capabilities: DEFAULT_CAPS,
|
|
});
|
|
expect(result.version).toBe("1.1.0-beta.2");
|
|
expect(db.rows.get("notes")).toMatchObject({
|
|
channel: "beta",
|
|
pinnedVersion: null,
|
|
});
|
|
});
|
|
|
|
it("moves every installed plugin to one channel", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
createFixturePlugin({ id: "notes", root: bundled });
|
|
await boot();
|
|
|
|
expect((await manage.setAllPluginChannels("beta")).changed.sort()).toEqual([
|
|
"docker",
|
|
"notes",
|
|
]);
|
|
expect(db.rows.get("docker")?.channel).toBe("beta");
|
|
expect((await manage.setAllPluginChannels("beta")).changed).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("dependencies", () => {
|
|
it("starts a dependency first and stops dependents first", async () => {
|
|
createFixturePlugin({ id: "base", root: bundled });
|
|
createFixturePlugin({
|
|
id: "addon",
|
|
root: bundled,
|
|
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
|
});
|
|
const loader = await boot();
|
|
|
|
const off = await manage.setPluginState("base", false);
|
|
expect(off.disable).toEqual(["addon"]);
|
|
expect(loader.get("addon")?.state).toBe("stopped");
|
|
expect(loader.get("base")?.state).toBe("stopped");
|
|
|
|
const plan = await manage.planStateChange("addon", true);
|
|
expect(plan.enable).toEqual(["base"]);
|
|
const on = await manage.setPluginState("addon", true);
|
|
expect(on.state).toBe("active");
|
|
expect(loader.get("base")?.state).toBe("active");
|
|
});
|
|
|
|
it("restarts dependents it stopped when turned back on", async () => {
|
|
createFixturePlugin({ id: "base", root: bundled });
|
|
createFixturePlugin({
|
|
id: "addon",
|
|
root: bundled,
|
|
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
|
});
|
|
createFixturePlugin({
|
|
id: "manual",
|
|
root: bundled,
|
|
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
|
});
|
|
const loader = await boot();
|
|
|
|
await manage.setPluginState("manual", false);
|
|
await manage.setPluginState("base", false);
|
|
expect(loader.get("addon")?.state).toBe("stopped");
|
|
|
|
await manage.setPluginState("base", true);
|
|
expect(loader.get("addon")?.state).toBe("active");
|
|
expect(db.rows.get("addon")).toMatchObject({
|
|
state: "enabled",
|
|
lastError: null,
|
|
});
|
|
expect(loader.get("manual")?.state).not.toBe("active");
|
|
expect(db.rows.get("manual")?.state).toBe("disabled");
|
|
});
|
|
|
|
it("refuses to uninstall a plugin a running one depends on", async () => {
|
|
createFixturePlugin({ id: "base", root: bundled });
|
|
createFixturePlugin({
|
|
id: "addon",
|
|
root: bundled,
|
|
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
|
});
|
|
await boot();
|
|
|
|
await expect(manage.uninstallPlugin("base")).rejects.toMatchObject({
|
|
code: "HAS_DEPENDENTS",
|
|
details: { dependents: ["addon"] },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("reloading a bundled plugin for the dev runner", () => {
|
|
it("loads the rebuilt copy and restarts what depends on it", async () => {
|
|
createFixturePlugin({ id: "base", root: bundled });
|
|
createFixturePlugin({
|
|
id: "addon",
|
|
root: bundled,
|
|
manifestOverrides: { dependencies: { base: "^1.0.0" } },
|
|
});
|
|
const loader = await boot();
|
|
createFixturePlugin({
|
|
id: "base",
|
|
root: bundled,
|
|
manifestOverrides: { version: "1.1.0" },
|
|
});
|
|
|
|
const result = await manage.reloadBundledPlugin("base");
|
|
expect(result).toEqual({ id: "base", version: "1.1.0", state: "active" });
|
|
expect(loader.get("base")?.manifest.version).toBe("1.1.0");
|
|
expect(loader.get("addon")?.state).toBe("active");
|
|
expect(db.rows.get("base")?.version).toBe("1.1.0");
|
|
});
|
|
|
|
it("loads and starts a plugin added since boot", async () => {
|
|
const loader = await boot();
|
|
createFixturePlugin({ id: "fresh", root: bundled });
|
|
const result = await manage.reloadBundledPlugin("fresh");
|
|
expect(result.state).toBe("active");
|
|
expect(loader.get("fresh")?.state).toBe("active");
|
|
});
|
|
|
|
it("keeps a disabled plugin stopped", async () => {
|
|
createFixturePlugin({ id: "base", root: bundled });
|
|
const loader = await boot();
|
|
await manage.setPluginState("base", false);
|
|
const result = await manage.reloadBundledPlugin("base");
|
|
expect(result.state).toBe("stopped");
|
|
expect(loader.get("base")?.state).toBe("loaded");
|
|
});
|
|
});
|
|
|
|
describe("consent", () => {
|
|
it("refuses an install without the consented list, before any download", async () => {
|
|
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
|
await boot();
|
|
await expect(
|
|
manage.installPlugin("notes", { userId: "admin" }),
|
|
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
|
expect(registry.download).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses when the consent does not match what the release asks for", async () => {
|
|
registry.index.plugins = [
|
|
registryEntry("notes", [
|
|
{ version: "1.0.0", capabilities: ["kv:own", "credentials:read"] },
|
|
]),
|
|
];
|
|
await boot();
|
|
await expect(
|
|
manage.installPlugin("notes", {
|
|
userId: "admin",
|
|
capabilities: ["kv:own"],
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: "CONSENT_MISMATCH",
|
|
details: { capabilities: ["kv:own", "credentials:read"] },
|
|
});
|
|
expect(registry.download).not.toHaveBeenCalled();
|
|
expect(db.rows.has("notes")).toBe(false);
|
|
});
|
|
|
|
it("accepts the same list in another order", async () => {
|
|
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
|
registry.download.mockImplementation(() => stageArtifact("notes"));
|
|
await boot();
|
|
const result = await manage.installPlugin("notes", {
|
|
userId: "admin",
|
|
capabilities: ["kv:own", "hosts:read"],
|
|
});
|
|
expect(result.state).toBe("active");
|
|
});
|
|
|
|
it("needs the added capabilities listed to accept an update", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
registry.index.plugins = [
|
|
registryEntry("docker", [
|
|
{
|
|
version: "1.1.0",
|
|
capabilities: ["hosts:read", "kv:own", "notify:send"],
|
|
},
|
|
{ version: "1.0.0" },
|
|
]),
|
|
];
|
|
await boot();
|
|
|
|
await expect(
|
|
manage.updatePlugin("docker", {
|
|
userId: "admin",
|
|
acceptCapabilities: true,
|
|
capabilities: ["ssh:connect"],
|
|
}),
|
|
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
|
await expect(
|
|
manage.updatePlugin("docker", {
|
|
userId: "admin",
|
|
acceptCapabilities: true,
|
|
}),
|
|
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
|
expect(registry.download).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("installing from a file", () => {
|
|
async function artifactBuffer(
|
|
id: string,
|
|
manifestOverrides: Record<string, unknown> = {},
|
|
) {
|
|
const staged = await stageArtifact(id, manifestOverrides);
|
|
const buffer = fs.readFileSync(staged.file);
|
|
fs.rmSync(staged.file);
|
|
return buffer;
|
|
}
|
|
|
|
afterEach(() => {
|
|
manage.resetUploads();
|
|
delete process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
|
});
|
|
|
|
it("is refused while developer mode is off", async () => {
|
|
await boot();
|
|
await expect(
|
|
manage.stageUpload(await artifactBuffer("notes")),
|
|
).rejects.toMatchObject({ code: "DEVELOPER_MODE_OFF" });
|
|
});
|
|
|
|
it("is refused when signed plugins are required", async () => {
|
|
await boot();
|
|
await manage.setDeveloperMode(true);
|
|
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
|
await expect(
|
|
manage.stageUpload(await artifactBuffer("notes")),
|
|
).rejects.toMatchObject({ code: "SIGNED_ONLY" });
|
|
});
|
|
|
|
it("stages, asks for consent, then installs it marked unverified", async () => {
|
|
const loader = await boot();
|
|
await manage.setDeveloperMode(true);
|
|
|
|
const preview = await manage.stageUpload(
|
|
await artifactBuffer("notes", { capabilities: ["kv:own"] }),
|
|
);
|
|
expect(preview).toMatchObject({
|
|
id: "notes",
|
|
capabilities: ["kv:own"],
|
|
replaces: null,
|
|
});
|
|
expect(loader.get("notes")).toBeUndefined();
|
|
|
|
await expect(
|
|
manage.installUpload(preview.token, {
|
|
userId: "admin",
|
|
capabilities: ["kv:own", "hosts:read"],
|
|
}),
|
|
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
|
|
|
const result = await manage.installUpload(preview.token, {
|
|
userId: "admin",
|
|
capabilities: ["kv:own"],
|
|
});
|
|
expect(result.state).toBe("active");
|
|
expect(db.rows.get("notes")).toMatchObject({
|
|
tier: manage.UNVERIFIED_TIER,
|
|
registryId: null,
|
|
});
|
|
expect(loader.get("notes")?.signedBy).toBeUndefined();
|
|
expect(fs.existsSync(path.join(data, "plugins", "notes.tmxplug"))).toBe(
|
|
true,
|
|
);
|
|
|
|
// A token is used once.
|
|
await expect(
|
|
manage.installUpload(preview.token, {
|
|
userId: "admin",
|
|
capabilities: ["kv:own"],
|
|
}),
|
|
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
|
});
|
|
|
|
it("replaces an earlier upload but never a bundled plugin", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
const loader = await boot();
|
|
await manage.setDeveloperMode(true);
|
|
|
|
await expect(
|
|
manage.stageUpload(await artifactBuffer("docker")),
|
|
).rejects.toMatchObject({ code: "BUNDLED_ID" });
|
|
|
|
const first = await manage.stageUpload(await artifactBuffer("notes"));
|
|
await manage.installUpload(first.token, {
|
|
userId: "admin",
|
|
capabilities: first.capabilities,
|
|
});
|
|
const second = await manage.stageUpload(
|
|
await artifactBuffer("notes", { version: "1.1.0" }),
|
|
);
|
|
expect(second.replaces).toBe("1.0.0");
|
|
await manage.installUpload(second.token, {
|
|
userId: "admin",
|
|
capabilities: second.capabilities,
|
|
});
|
|
expect(loader.get("notes")?.manifest.version).toBe("1.1.0");
|
|
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
|
});
|
|
|
|
it("refuses a file that is not a plugin archive", async () => {
|
|
await boot();
|
|
await manage.setDeveloperMode(true);
|
|
await expect(
|
|
manage.stageUpload(Buffer.from("not a tarball")),
|
|
).rejects.toMatchObject({ code: "INVALID_ARTIFACT" });
|
|
});
|
|
|
|
it("drops staged files when developer mode is turned off", async () => {
|
|
await boot();
|
|
await manage.setDeveloperMode(true);
|
|
const preview = await manage.stageUpload(await artifactBuffer("notes"));
|
|
await manage.setDeveloperMode(false);
|
|
await manage.setDeveloperMode(true);
|
|
await expect(
|
|
manage.installUpload(preview.token, {
|
|
userId: "admin",
|
|
capabilities: preview.capabilities,
|
|
}),
|
|
).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" });
|
|
});
|
|
});
|
|
|
|
describe("release notes", () => {
|
|
it("reads an installed plugin's CHANGELOG.md", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
fs.writeFileSync(
|
|
path.join(bundled, "docker", "CHANGELOG.md"),
|
|
"# Changelog\n\n## 1.1.0 - 2026-10-06\n\n### Fixed\n- A bug\n\n## 1.0.0\n\n### Added\n- First\n",
|
|
);
|
|
await boot();
|
|
expect(await manage.getPluginChangelog("docker")).toEqual([
|
|
{
|
|
version: "1.1.0",
|
|
date: "2026-10-06",
|
|
changes: [{ type: "fixed", text: "A bug" }],
|
|
},
|
|
{ version: "1.0.0", changes: [{ type: "added", text: "First" }] },
|
|
]);
|
|
});
|
|
|
|
it("is empty without a changelog and 404s for an unknown plugin", async () => {
|
|
createFixturePlugin({ id: "docker", root: bundled });
|
|
await boot();
|
|
expect(await manage.getPluginChangelog("docker")).toEqual([]);
|
|
await expect(manage.getPluginChangelog("nope")).rejects.toMatchObject({
|
|
status: 404,
|
|
});
|
|
});
|
|
|
|
it("parses registry notes and drops empty ones", () => {
|
|
expect(manage.readReleaseNotes("Summary.\n\n### Added\n- Thing")).toEqual({
|
|
summary: "Summary.",
|
|
changes: [{ type: "added", text: "Thing" }],
|
|
});
|
|
expect(manage.readReleaseNotes("")).toBeUndefined();
|
|
expect(manage.readReleaseNotes(undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("more registries", () => {
|
|
const customKey = (() => {
|
|
const { publicKey } = crypto.generateKeyPairSync("ed25519");
|
|
return publicKey
|
|
.export({ format: "der", type: "spki" })
|
|
.subarray(12)
|
|
.toString("base64");
|
|
})();
|
|
|
|
function turnOnCommunity(): void {
|
|
db.settings.set(
|
|
"plugin_registries",
|
|
JSON.stringify({ community: { enabled: true }, custom: [] }),
|
|
);
|
|
}
|
|
|
|
it("lists community plugins only when it is on, never official ids", async () => {
|
|
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
|
registry.others.community = {
|
|
registry: "community",
|
|
name: "Community",
|
|
plugins: [
|
|
registryEntry("docker", [{ version: "9.0.0" }]),
|
|
registryEntry("neat", [{ version: "1.0.0" }]),
|
|
],
|
|
};
|
|
await boot();
|
|
|
|
let listing = await manage.listRegistry();
|
|
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
|
|
expect(listing.registries.map((r) => r.id)).toEqual(["official"]);
|
|
|
|
turnOnCommunity();
|
|
listing = await manage.listRegistry();
|
|
expect(
|
|
listing.plugins.map((p) => [p.id, p.registryId, p.tier, p.latestVersion]),
|
|
).toEqual([
|
|
["docker", "official", "official", "1.0.0"],
|
|
["neat", "community", "community", "1.0.0"],
|
|
]);
|
|
expect(listing.registries.find((r) => r.id === "community")).toMatchObject({
|
|
tier: "community",
|
|
plugins: 1,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it("keeps listing official plugins when another registry is down", async () => {
|
|
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
|
turnOnCommunity();
|
|
await boot();
|
|
const listing = await manage.listRegistry();
|
|
expect(listing.plugins.map((p) => p.id)).toEqual(["docker"]);
|
|
expect(listing.registries.find((r) => r.id === "community")?.error).toMatch(
|
|
/offline/,
|
|
);
|
|
});
|
|
|
|
it("installs from the community registry with its keys and records it", async () => {
|
|
registry.others.community = {
|
|
registry: "community",
|
|
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
|
|
};
|
|
turnOnCommunity();
|
|
await boot();
|
|
registry.download.mockImplementation(() => stageArtifact("neat"));
|
|
|
|
const result = await manage.installPlugin("neat", {
|
|
userId: "admin",
|
|
registryId: "community",
|
|
capabilities: DEFAULT_CAPS,
|
|
});
|
|
expect(result.state).toBe("active");
|
|
const keys = registry.download.mock.calls[0][2] as Array<{ id: string }>;
|
|
expect(keys.map((key) => key.id)).toEqual(["be5b24ea07b36624"]);
|
|
expect(db.rows.get("neat")).toMatchObject({
|
|
registryId: "community",
|
|
tier: "community",
|
|
});
|
|
});
|
|
|
|
it("updates a plugin from the registry it came from", async () => {
|
|
registry.others.community = {
|
|
registry: "community",
|
|
plugins: [
|
|
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
|
],
|
|
};
|
|
turnOnCommunity();
|
|
await boot();
|
|
registry.download.mockImplementation(() =>
|
|
stageArtifact("neat", { version: "1.0.0" }),
|
|
);
|
|
await manage.installPlugin("neat", {
|
|
userId: "admin",
|
|
registryId: "community",
|
|
version: "1.0.0",
|
|
capabilities: DEFAULT_CAPS,
|
|
});
|
|
await manage.setPluginOptions("neat", { pinned: false });
|
|
|
|
registry.download.mockReset();
|
|
registry.download.mockImplementation(() =>
|
|
stageArtifact("neat", { version: "1.1.0" }),
|
|
);
|
|
const result = await manage.updatePlugin("neat", { userId: "admin" });
|
|
expect(result.version).toBe("1.1.0");
|
|
const [, release, keys] = registry.download.mock.calls[0];
|
|
expect(release.url).toContain("neat-1.1.0");
|
|
expect(keys.map((key: { id: string }) => key.id)).toEqual([
|
|
"be5b24ea07b36624",
|
|
]);
|
|
expect(db.rows.get("neat")).toMatchObject({ registryId: "community" });
|
|
});
|
|
|
|
it("stops updating a community plugin once the official registry lists its id", async () => {
|
|
registry.others.community = {
|
|
registry: "community",
|
|
plugins: [
|
|
registryEntry("neat", [{ version: "1.1.0" }, { version: "1.0.0" }]),
|
|
],
|
|
};
|
|
turnOnCommunity();
|
|
await boot();
|
|
registry.download.mockImplementation(() => stageArtifact("neat"));
|
|
await manage.installPlugin("neat", {
|
|
userId: "admin",
|
|
registryId: "community",
|
|
version: "1.0.0",
|
|
capabilities: DEFAULT_CAPS,
|
|
});
|
|
registry.index.plugins = [registryEntry("neat", [{ version: "5.0.0" }])];
|
|
await expect(
|
|
manage.updatePlugin("neat", { userId: "admin", version: "1.1.0" }),
|
|
).rejects.toMatchObject({ code: "RESERVED_ID" });
|
|
});
|
|
|
|
it("refuses an official id from another registry", async () => {
|
|
registry.index.plugins = [registryEntry("docker", [{ version: "1.0.0" }])];
|
|
registry.others.community = {
|
|
registry: "community",
|
|
plugins: [registryEntry("docker", [{ version: "9.0.0" }])],
|
|
};
|
|
turnOnCommunity();
|
|
await boot();
|
|
await expect(
|
|
manage.installPlugin("docker", {
|
|
userId: "admin",
|
|
registryId: "community",
|
|
capabilities: DEFAULT_CAPS,
|
|
}),
|
|
).rejects.toMatchObject({ code: "RESERVED_ID" });
|
|
expect(registry.download).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a registry that is off", async () => {
|
|
registry.others.community = {
|
|
registry: "community",
|
|
plugins: [registryEntry("neat", [{ version: "1.0.0" }])],
|
|
};
|
|
await boot();
|
|
await expect(
|
|
manage.installPlugin("neat", {
|
|
userId: "admin",
|
|
registryId: "community",
|
|
capabilities: DEFAULT_CAPS,
|
|
}),
|
|
).rejects.toMatchObject({ code: "REGISTRY_OFF" });
|
|
});
|
|
|
|
it("stops trusting a custom registry whose index names a new key", async () => {
|
|
const other = crypto
|
|
.generateKeyPairSync("ed25519")
|
|
.publicKey.export({ format: "der", type: "spki" })
|
|
.subarray(12)
|
|
.toString("base64");
|
|
db.settings.set(
|
|
"plugin_registries",
|
|
JSON.stringify({
|
|
community: { enabled: false },
|
|
custom: [
|
|
{
|
|
id: "mine",
|
|
name: "Mine",
|
|
url: "https://example.com/index.json",
|
|
publicKey: customKey,
|
|
enabled: true,
|
|
addedAt: "2026-10-11T00:00:00Z",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
registry.others.mine = {
|
|
registry: "mine",
|
|
publicKey: customKey,
|
|
plugins: [registryEntry("tool", [{ version: "1.0.0" }])],
|
|
};
|
|
await boot();
|
|
let listing = await manage.listRegistry();
|
|
expect(listing.plugins.find((p) => p.id === "tool")?.tier).toBe("custom");
|
|
|
|
registry.others.mine.publicKey = other;
|
|
listing = await manage.listRegistry();
|
|
expect(listing.plugins.find((p) => p.id === "tool")).toBeUndefined();
|
|
expect(listing.registries.find((r) => r.id === "mine")?.error).toMatch(
|
|
/different signing key/,
|
|
);
|
|
await expect(
|
|
manage.installPlugin("tool", {
|
|
userId: "admin",
|
|
registryId: "mine",
|
|
capabilities: DEFAULT_CAPS,
|
|
}),
|
|
).rejects.toMatchObject({ code: "KEY_CHANGED" });
|
|
});
|
|
});
|