mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: implement plugin management UI
This commit is contained in:
1 parent
88d7bc5525
commit
3ed5775ed5
52 files changed
+20072
-41
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const manage = vi.hoisted(() => ({
|
||||
isLinkedDesktop: vi.fn(async () => false),
|
||||
updateAllPlugins: vi.fn(),
|
||||
}));
|
||||
const notify = vi.hoisted(() => ({ sendCoreAlert: vi.fn(async () => {}) }));
|
||||
|
||||
vi.mock("../../plugins/manage.js", () => manage);
|
||||
vi.mock("../../notify/core-notify.js", () => notify);
|
||||
|
||||
import { runPluginAutoUpdate } from "../../plugins/auto-update.js";
|
||||
|
||||
beforeEach(() => {
|
||||
manage.isLinkedDesktop.mockResolvedValue(false);
|
||||
manage.updateAllPlugins.mockReset();
|
||||
notify.sendCoreAlert.mockClear();
|
||||
});
|
||||
|
||||
describe("runPluginAutoUpdate", () => {
|
||||
it("only touches plugins with auto-update on", async () => {
|
||||
manage.updateAllPlugins.mockResolvedValue({
|
||||
updated: [{ id: "docker", version: "1.1.0" }],
|
||||
needsReview: [],
|
||||
failed: [],
|
||||
});
|
||||
await runPluginAutoUpdate();
|
||||
expect(manage.updateAllPlugins).toHaveBeenCalledWith({
|
||||
userId: null,
|
||||
onlyAutoUpdate: true,
|
||||
});
|
||||
expect(notify.sendCoreAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("alerts admins about an update that asks for more", async () => {
|
||||
manage.updateAllPlugins.mockResolvedValue({
|
||||
updated: [],
|
||||
needsReview: [{ id: "docker", capabilities: ["notify:send"] }],
|
||||
failed: [],
|
||||
});
|
||||
await runPluginAutoUpdate();
|
||||
expect(notify.sendCoreAlert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
audience: "admins",
|
||||
dedupeKey: "plugin-review:docker:notify:send",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing on a linked desktop", async () => {
|
||||
manage.isLinkedDesktop.mockResolvedValue(true);
|
||||
await runPluginAutoUpdate();
|
||||
expect(manage.updateAllPlugins).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { moveWithRetry } from "../../plugins/fs-retry.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
function tempDir(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-move-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function locked(): NodeJS.ErrnoException {
|
||||
return Object.assign(new Error("locked"), { code: "EPERM" });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (roots.length) {
|
||||
fs.rmSync(roots.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("moveWithRetry", () => {
|
||||
it("waits out a passing lock", async () => {
|
||||
const root = tempDir();
|
||||
fs.mkdirSync(path.join(root, "from"));
|
||||
const real = fs.promises.rename;
|
||||
const rename = vi
|
||||
.spyOn(fs.promises, "rename")
|
||||
.mockRejectedValueOnce(locked())
|
||||
.mockImplementation(real);
|
||||
|
||||
await moveWithRetry(path.join(root, "from"), path.join(root, "to"), [1, 1]);
|
||||
|
||||
expect(rename).toHaveBeenCalledTimes(2);
|
||||
expect(fs.existsSync(path.join(root, "to"))).toBe(true);
|
||||
});
|
||||
|
||||
it("copies a folder that never unlocks", async () => {
|
||||
const root = tempDir();
|
||||
fs.mkdirSync(path.join(root, "from", "nested"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "from", "nested", "a.js"), "x");
|
||||
vi.spyOn(fs.promises, "rename").mockRejectedValue(locked());
|
||||
|
||||
await moveWithRetry(path.join(root, "from"), path.join(root, "to"), [1]);
|
||||
|
||||
expect(
|
||||
fs.readFileSync(path.join(root, "to", "nested", "a.js"), "utf8"),
|
||||
).toBe("x");
|
||||
expect(fs.existsSync(path.join(root, "from"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry a real error", async () => {
|
||||
const root = tempDir();
|
||||
await expect(
|
||||
moveWithRetry(path.join(root, "missing"), path.join(root, "to"), [1]),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PluginLoader } from "../../plugins/loader.js";
|
||||
import { PluginLoader, assertBundledUpdate } from "../../plugins/loader.js";
|
||||
import { createFixturePlugin } from "./fixture-plugin.js";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -484,3 +484,68 @@ describe("PluginLoader error budget", () => {
|
||||
expect(loader.get("sample-plugin")?.lastError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader uninstall and pins", () => {
|
||||
it("ignores the shipped copy of a bundled plugin an admin uninstalled", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({ id: "ssh-terminal", root: bundled });
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
loader.setUninstalled(["docker"]);
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded.map((plugin) => plugin.id)).toEqual(["ssh-terminal"]);
|
||||
expect(loader.bundledVersion("docker")).toBeUndefined();
|
||||
expect(loader.bundledIds().has("docker")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads a downloaded copy of an uninstalled bundled id as a normal plugin", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
const userPlugins = path.join(user, "plugins");
|
||||
fs.mkdirSync(userPlugins, { recursive: true });
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
createFixturePlugin({ id: "docker", root: userPlugins });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
loader.setUninstalled(["docker"]);
|
||||
const loaded = await loader.loadAll();
|
||||
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0].source).toBe("user");
|
||||
});
|
||||
|
||||
it("loads the shipped copy again on reinstall", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
loader.setUninstalled(["docker"]);
|
||||
await loader.loadAll();
|
||||
const plugin = await loader.loadBundled("docker");
|
||||
|
||||
expect(plugin.source).toBe("bundled");
|
||||
expect(loader.get("docker")).toBe(plugin);
|
||||
});
|
||||
|
||||
it("refuses an older signed update unless that exact version is pinned", () => {
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0"),
|
||||
).toThrow(/not newer/);
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0", "0.9.0"),
|
||||
).toThrow(/not newer/);
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", "key", "1.1.0", "1.0.0"),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertBundledUpdate("docker", "1.0.0", undefined, "1.1.0", "1.0.0"),
|
||||
).toThrow(/signed/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 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 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[] },
|
||||
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 () => actual.parseRegistryIndex(registry.index),
|
||||
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;
|
||||
}
|
||||
|
||||
function registryEntry(
|
||||
id: string,
|
||||
versions: Array<{ version: string; capabilities?: string[] }>,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
versions: versions.map((v) => ({
|
||||
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"],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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.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" });
|
||||
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" });
|
||||
|
||||
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" });
|
||||
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" });
|
||||
|
||||
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("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("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"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
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";
|
||||
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
const testKey = vi.hoisted(() => ({ value: null as null | object }));
|
||||
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: fetchMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../plugins/trust.js")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.value as never,
|
||||
]),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
downloadRelease,
|
||||
fetchRegistryIndex,
|
||||
findRelease,
|
||||
isApiCompatible,
|
||||
parseRegistryIndex,
|
||||
resetRegistryCache,
|
||||
type RegistryVersion,
|
||||
} from "../../plugins/registry-index.js";
|
||||
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
testKey.value = {
|
||||
id: "test",
|
||||
publicKey: publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64"),
|
||||
addedIn: "26.10.0",
|
||||
};
|
||||
|
||||
function sign(buffer: Buffer) {
|
||||
const digest = crypto.createHash("sha256").update(buffer).digest();
|
||||
return {
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, privateKey).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function response(body: string | Buffer, status = 200, headers = {}) {
|
||||
return new Response(status >= 300 && status < 400 ? null : body, {
|
||||
status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function version(overrides: Partial<RegistryVersion> = {}) {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
api: "1",
|
||||
url: "https://example.com/p-1.0.0.tmxplug",
|
||||
sha256: "a".repeat(64),
|
||||
signature: "sig",
|
||||
size: 10,
|
||||
capabilities: ["kv:own"],
|
||||
publishedAt: "2026-10-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-registry-"));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
fetchMock.mockReset();
|
||||
resetRegistryCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parseRegistryIndex", () => {
|
||||
it("keeps valid entries and sorts versions newest first", () => {
|
||||
const index = parseRegistryIndex({
|
||||
registry: "official",
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
versions: [version(), version({ version: "1.2.0" })],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(index.plugins).toHaveLength(1);
|
||||
expect(index.plugins[0].versions.map((v) => v.version)).toEqual([
|
||||
"1.2.0",
|
||||
"1.0.0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops entries with a bad id, no https url or a malformed sha256", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{ id: "../evil", versions: [version()] },
|
||||
{ id: "plain", versions: [version({ url: "http://x/y.tmxplug" })] },
|
||||
{ id: "short", versions: [version({ sha256: "abc" })] },
|
||||
{ id: "ok", versions: [version()] },
|
||||
],
|
||||
});
|
||||
expect(index.plugins.map((p) => p.id)).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("refuses something that is not an index", () => {
|
||||
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRelease", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
versions: [
|
||||
version({ version: "2.0.0", api: "2" }),
|
||||
version({ version: "1.1.0" }),
|
||||
version(),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("picks the newest release this build can run", () => {
|
||||
expect(isApiCompatible("1")).toBe(true);
|
||||
expect(isApiCompatible("2")).toBe(false);
|
||||
expect(findRelease(index, "docker")?.release.version).toBe("1.1.0");
|
||||
});
|
||||
|
||||
it("finds an exact version", () => {
|
||||
expect(findRelease(index, "docker", "1.0.0")?.release.version).toBe(
|
||||
"1.0.0",
|
||||
);
|
||||
expect(findRelease(index, "docker", "9.9.9")).toBeNull();
|
||||
expect(findRelease(index, "missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRegistryIndex", () => {
|
||||
const body = JSON.stringify({
|
||||
plugins: [{ id: "docker", versions: [version()] }],
|
||||
});
|
||||
|
||||
it("follows redirects and caches the result", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
response("", 302, { location: "https://cdn.example.com/index.json" }),
|
||||
)
|
||||
.mockResolvedValueOnce(response(body));
|
||||
|
||||
const first = await fetchRegistryIndex();
|
||||
const second = await fetchRegistryIndex();
|
||||
expect(first.plugins[0].id).toBe("docker");
|
||||
expect(second).toBe(first);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(
|
||||
"https://cdn.example.com/index.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("serves the stale copy when a refresh fails", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response(body));
|
||||
const first = await fetchRegistryIndex();
|
||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||
expect(await fetchRegistryIndex({ force: true })).toBe(first);
|
||||
});
|
||||
|
||||
it("throws when there is nothing cached", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||
await expect(fetchRegistryIndex()).rejects.toThrow(/503/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadRelease", () => {
|
||||
const artifact = Buffer.from("plugin archive bytes");
|
||||
|
||||
it("writes a verified artifact and its signature to staging", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
|
||||
const result = await downloadRelease(
|
||||
"docker",
|
||||
version({ ...signed, size: artifact.length }),
|
||||
);
|
||||
expect(fs.readFileSync(result.file)).toEqual(artifact);
|
||||
expect(fs.readFileSync(result.signatureFile, "utf8")).toBe(
|
||||
signed.signature,
|
||||
);
|
||||
expect(
|
||||
result.file.startsWith(path.join(dataDir, "plugins", ".downloads")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a file that does not match its signature", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(Buffer.from("something else!!!")));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: 0 })),
|
||||
).rejects.toThrow(/sha256/);
|
||||
});
|
||||
|
||||
it("refuses a release built for another plugin API", async () => {
|
||||
await expect(
|
||||
downloadRelease("docker", version({ api: "2" })),
|
||||
).rejects.toThrow(/plugin API/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a download larger than the index says", async () => {
|
||||
const signed = sign(artifact);
|
||||
fetchMock.mockResolvedValueOnce(response(Buffer.alloc(3 * 1024 * 1024)));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: 10 })),
|
||||
).rejects.toThrow(/larger than allowed/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user