Files
Termix/src/backend/tests/plugins/manage.test.ts
T

1206 lines
39 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", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/permissions.js")>()),
}));
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("refuses to uninstall the default look but lets it be turned off", async () => {
createFixturePlugin({ id: "some-look", root: bundled });
fs.writeFileSync(
path.join(bundled, "bundled-index.json"),
JSON.stringify({
version: 1,
plugins: { "some-look": { defaultLook: true } },
}),
);
const { resetBundledIndexCache } =
await import("../../plugins/bundled-index.js");
resetBundledIndexCache();
try {
const loader = await boot();
await expect(manage.uninstallPlugin("some-look")).rejects.toMatchObject({
code: "DEFAULT_LOOK",
status: 409,
});
expect(loader.get("some-look")?.state).toBe("active");
expect(fs.existsSync(path.join(bundled, "some-look"))).toBe(true);
} finally {
fs.rmSync(path.join(bundled, "bundled-index.json"), { force: true });
resetBundledIndexCache();
}
});
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("restoring the bundled copy", () => {
it("drops a downloaded update, loads the shipped copy and pins it", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
// What a signed update leaves behind next to the shipped copy.
const artifact = path.join(data, "plugins", "docker.tmxplug");
fs.mkdirSync(path.dirname(artifact), { recursive: true });
fs.writeFileSync(artifact, "update");
fs.writeFileSync(`${artifact}.sig`, "sig");
const result = await manage.restoreBundledPlugin("docker");
expect(result).toEqual({ id: "docker", version: "1.0.0", state: "active" });
expect(loader.get("docker")?.manifest.version).toBe("1.0.0");
expect(fs.existsSync(artifact)).toBe(false);
expect(fs.existsSync(`${artifact}.sig`)).toBe(false);
expect(db.rows.get("docker")).toMatchObject({
pinnedVersion: "1.0.0",
autoUpdate: false,
});
});
it("keeps a plugin that is turned off stopped", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
await manage.setPluginState("docker", false);
const result = await manage.restoreBundledPlugin("docker");
expect(result.state).toBe("stopped");
expect(loader.get("docker")?.state).toBe("loaded");
});
it("refuses a plugin that has no bundled copy", async () => {
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
await boot();
await expect(manage.restoreBundledPlugin("notes")).rejects.toMatchObject({
code: "NO_BUNDLED_COPY",
status: 409,
});
});
it("refuses a bad id", async () => {
await expect(manage.restoreBundledPlugin("../x")).rejects.toMatchObject({
code: "INVALID_ID",
status: 400,
});
});
});
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" });
});
});
it("requires exact consent before enabling a mirrored plugin with capabilities", async () => {
createFixturePlugin({
id: "manual-mirror",
root: path.join(data, "plugins"),
capabilities: ["kv:own"],
backendSource:
'export async function activate(ctx) { await ctx.kv.set("probe", "value"); }',
});
await boot();
const options = {
version: "1.0.0",
capabilities: ["kv:own"],
userId: "admin",
};
await expect(
manage.enablePluginWithConsent("manual-mirror", {
...options,
version: "0.9.0",
}),
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
await expect(
manage.enablePluginWithConsent("manual-mirror", {
...options,
capabilities: [],
}),
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
await expect(
manage.enablePluginWithConsent("manual-mirror", {
version: "1.0.0",
userId: "admin",
}),
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
expect(db.grants).toEqual([]);
expect(db.rows.get("manual-mirror")?.state).toBe("disabled");
const result = await manage.enablePluginWithConsent("manual-mirror", options);
expect(result.state).toBe("active");
expect(db.grants).toContainEqual(
expect.objectContaining({
pluginId: "manual-mirror",
capability: "kv:own",
grantedBy: "admin",
}),
);
});