feat: implement plugin management UI

This commit is contained in:
LukeGus committed 2026-10-06 10:35:56 -05:00
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" });
});
});
+66 -1
View File
@@ -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/);
});
});
+431
View File
@@ -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/);
});
});