mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
v26.10.1 (#1599)
* feat: AI translations at release, 7 new languages and regional fallback * fix: proper plural forms for count strings and full translations * fix: plural forms for selected, hidden and active counts * fix: synced plugin updates failing on desktop and server-only plugins shown as stopped * fix: restore rounded corners on app icons and favicon * feat: desktop app auto updates with automatic, ask first or never modes * feat: community and custom plugin registries * feat: plugin guide for AI tools in the SDK with a lint check * fix: keep the terminal WebSocket open past nginx's 60s default (#1588) * chore: update changelog * feat: show plugin download counts only and stop CI builds inflating them * feat: hide installed plugins in the browse tab by default * chore: pin bundled plugins to latest releases * chore: update translations * fix: focusing a saved password field and saving no longer erases it * fix: tell desktop users when their 2.8 data needs 2.9 first * fix: point windows update info at the x64 installer * fix: keep desktop client tunnel sockets alive behind proxies * chore: pin bundled plugins to latest releases * fix: cover client tunnel keepalive in the c2s runtime tests * fix: typing after opening a host no longer goes to the host search * fix: accept tab and = separators in SSH config import (#1598) * chore: update changelog * chore: drop plugin versions from core changelog * chore: update translations * chore: lint, format, and bump version to 26.10.1 --------- Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com> Co-authored-by: Iain <Iain1970@protonmail.com>
This commit is contained in:
174 files changed
+94334
-39995
No files matched your search
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stats: null as null | { plugins: Map<string, number> },
|
||||
fail: false,
|
||||
sources: [{ id: "official", tier: "official" }] as Array<{
|
||||
id: string;
|
||||
tier: string;
|
||||
}>,
|
||||
statsBySource: {} as Record<string, { plugins: Map<string, number> } | null>,
|
||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||
rows: [] as Array<{ pluginId: string; count: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", () => ({
|
||||
OFFICIAL_REGISTRY_ID: "official",
|
||||
officialSource: () => ({ id: "official", tier: "official" }),
|
||||
fetchRegistryStats: async (source: { id: string }) => {
|
||||
if (state.fail) throw new Error("offline");
|
||||
return state.statsBySource[source.id] ?? state.stats;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registries.js", () => ({
|
||||
listSources: async () => state.sources,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginInstallCountRepository: () => ({
|
||||
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||
state.upserted.push({ registryId, entries });
|
||||
return entries.length;
|
||||
},
|
||||
listByRegistry: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
readDownloadCounts,
|
||||
refreshDownloadCounts,
|
||||
resetDownloadCountRefresh,
|
||||
syncDownloadCounts,
|
||||
} = await import("../../plugins/download-counts.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stats = null;
|
||||
state.fail = false;
|
||||
state.sources = [{ id: "official", tier: "official" }];
|
||||
state.statsBySource = {};
|
||||
state.upserted = [];
|
||||
state.rows = [];
|
||||
resetDownloadCountRefresh();
|
||||
});
|
||||
|
||||
describe("refreshDownloadCounts", () => {
|
||||
const docker = () => ({ plugins: new Map([["docker", 5]]) });
|
||||
|
||||
it("syncs on the first look and not again while fresh", async () => {
|
||||
state.stats = docker();
|
||||
await refreshDownloadCounts();
|
||||
await refreshDownloadCounts();
|
||||
expect(state.upserted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not sync again right away even when forced", async () => {
|
||||
state.stats = docker();
|
||||
await refreshDownloadCounts();
|
||||
await refreshDownloadCounts(true);
|
||||
expect(state.upserted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("syncs again when forced after a minute", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
state.stats = docker();
|
||||
await refreshDownloadCounts();
|
||||
vi.setSystemTime(Date.now() + 61_000);
|
||||
await refreshDownloadCounts();
|
||||
expect(state.upserted).toHaveLength(1);
|
||||
await refreshDownloadCounts(true);
|
||||
expect(state.upserted).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncDownloadCounts", () => {
|
||||
it("writes one row per plugin", async () => {
|
||||
state.stats = {
|
||||
plugins: new Map([
|
||||
["docker", 50],
|
||||
["tunnels", 8],
|
||||
]),
|
||||
};
|
||||
expect(await syncDownloadCounts()).toBe(2);
|
||||
expect(state.upserted).toEqual([
|
||||
{
|
||||
registryId: "official",
|
||||
entries: [
|
||||
{ pluginId: "docker", count: 50 },
|
||||
{ pluginId: "tunnels", count: 8 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores counts per registry and skips custom ones", async () => {
|
||||
state.sources = [
|
||||
{ id: "official", tier: "official" },
|
||||
{ id: "community", tier: "community" },
|
||||
{ id: "mine", tier: "custom" },
|
||||
];
|
||||
state.statsBySource = {
|
||||
official: { plugins: new Map([["docker", 5]]) },
|
||||
community: { plugins: new Map([["neat", 3]]) },
|
||||
mine: null,
|
||||
};
|
||||
expect(await syncDownloadCounts()).toBe(2);
|
||||
expect(state.upserted.map((entry) => entry.registryId)).toEqual([
|
||||
"official",
|
||||
"community",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||
state.fail = true;
|
||||
expect(await syncDownloadCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing for a registry with no stats file", async () => {
|
||||
expect(await syncDownloadCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readDownloadCounts", () => {
|
||||
it("maps the stored rows by plugin id", async () => {
|
||||
state.rows = [{ pluginId: "docker", count: 20 }];
|
||||
const counts = await readDownloadCounts();
|
||||
expect(counts.get("docker")).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isBareSpecifier,
|
||||
isUserPluginFile,
|
||||
} from "../../plugins/host-modules.js";
|
||||
|
||||
describe("isBareSpecifier", () => {
|
||||
it("accepts package names", () => {
|
||||
expect(isBareSpecifier("drizzle-orm")).toBe(true);
|
||||
expect(isBareSpecifier("drizzle-orm/sqlite-core")).toBe(true);
|
||||
expect(isBareSpecifier("@termix-ssh/plugin-sdk/backend")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses paths, urls and builtins", () => {
|
||||
expect(isBareSpecifier("./local.js")).toBe(false);
|
||||
expect(isBareSpecifier("../up.js")).toBe(false);
|
||||
expect(isBareSpecifier("/abs/file.js")).toBe(false);
|
||||
expect(isBareSpecifier("file:///x.js")).toBe(false);
|
||||
expect(isBareSpecifier("node:fs")).toBe(false);
|
||||
expect(isBareSpecifier("C:\\x\\y.js")).toBe(false);
|
||||
expect(isBareSpecifier("#internal")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUserPluginFile", () => {
|
||||
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
|
||||
|
||||
it("matches files under the plugins dir, query included", () => {
|
||||
const file = path.join(root, ".unpacked", "demo", "dist", "backend.js");
|
||||
expect(isUserPluginFile(`${pathToFileURL(file).href}?v=1`, root)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores everything else", () => {
|
||||
const outside = path.resolve(os.tmpdir(), "termix-data", "other.js");
|
||||
expect(isUserPluginFile(pathToFileURL(outside).href, root)).toBe(false);
|
||||
expect(isUserPluginFile(pathToFileURL(root).href, root)).toBe(false);
|
||||
expect(isUserPluginFile(undefined, root)).toBe(false);
|
||||
expect(isUserPluginFile("data:text/javascript,1", root)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installHostModuleFallback", () => {
|
||||
let dataDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
dataDir = null;
|
||||
});
|
||||
|
||||
// A real Node process: vitest resolves imports on its own and would hide
|
||||
// what plain Node does.
|
||||
function importFromPlugin(withFallback: boolean) {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-host-modules-"));
|
||||
const dir = path.join(dataDir, "plugins", ".unpacked", "demo");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const entry = path.join(dir, "backend.mjs");
|
||||
fs.writeFileSync(
|
||||
entry,
|
||||
'import { sql } from "drizzle-orm";\nconsole.log(typeof sql);\n',
|
||||
);
|
||||
const hook = pathToFileURL(
|
||||
path.resolve(__dirname, "../../plugins/host-modules.ts"),
|
||||
).href;
|
||||
const script = [
|
||||
`const { installHostModuleFallback } = await import(${JSON.stringify(hook)});`,
|
||||
withFallback ? "installHostModuleFallback();" : "",
|
||||
`await import(${JSON.stringify(`${pathToFileURL(entry).href}?v=1`)});`,
|
||||
].join("\n");
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--input-type=module", "-e", script],
|
||||
{
|
||||
cwd: path.resolve(__dirname, "../../../.."),
|
||||
env: { ...process.env, DATA_DIR: dataDir },
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("lets a plugin outside the app import core's packages", () => {
|
||||
const result = importFromPlugin(true);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("function");
|
||||
});
|
||||
|
||||
it("fails the same import without it", () => {
|
||||
const result = importFromPlugin(false);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("Cannot find package 'drizzle-orm'");
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
stats: null as null | {
|
||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
||||
},
|
||||
fail: false,
|
||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||
rows: [] as Array<{ pluginId: string; count: number; source: string }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", () => ({
|
||||
OFFICIAL_REGISTRY_ID: "official",
|
||||
fetchRegistryStats: async () => {
|
||||
if (state.fail) throw new Error("offline");
|
||||
return state.stats;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginInstallCountRepository: () => ({
|
||||
upsertMany: async (registryId: string, entries: unknown[]) => {
|
||||
state.upserted.push({ registryId, entries });
|
||||
return entries.length;
|
||||
},
|
||||
listByRegistry: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
DOWNLOADS_SOURCE,
|
||||
TELEMETRY_SOURCE,
|
||||
preferredCount,
|
||||
readInstallCounts,
|
||||
syncInstallCounts,
|
||||
} = await import("../../plugins/install-counts.js");
|
||||
|
||||
beforeEach(() => {
|
||||
state.stats = null;
|
||||
state.fail = false;
|
||||
state.upserted = [];
|
||||
state.rows = [];
|
||||
});
|
||||
|
||||
describe("preferredCount", () => {
|
||||
it("prefers active installs and falls back to downloads", () => {
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
|
||||
count: 12,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({
|
||||
count: 90,
|
||||
source: DOWNLOADS_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncInstallCounts", () => {
|
||||
it("writes one row per plugin with its preferred source", async () => {
|
||||
state.stats = {
|
||||
plugins: new Map([
|
||||
["docker", { downloads: 50, activeInstalls: 20 }],
|
||||
["tunnels", { downloads: 8, activeInstalls: null }],
|
||||
]),
|
||||
};
|
||||
expect(await syncInstallCounts()).toBe(2);
|
||||
expect(state.upserted).toEqual([
|
||||
{
|
||||
registryId: "official",
|
||||
entries: [
|
||||
{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE },
|
||||
{ pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||
state.fail = true;
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing for a registry with no stats file", async () => {
|
||||
expect(await syncInstallCounts()).toBe(0);
|
||||
expect(state.upserted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readInstallCounts", () => {
|
||||
it("maps the stored rows by plugin id", async () => {
|
||||
state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }];
|
||||
const counts = await readInstallCounts();
|
||||
expect(counts.get("docker")).toEqual({
|
||||
count: 20,
|
||||
source: TELEMETRY_SOURCE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -33,6 +34,8 @@ const db = vi.hoisted(() => ({
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
@@ -97,7 +100,14 @@ vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchRegistryIndex: async () => actual.parseRegistryIndex(registry.index),
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -216,6 +226,7 @@ beforeEach(() => {
|
||||
db.settings.clear();
|
||||
db.grants.length = 0;
|
||||
registry.index.plugins = [];
|
||||
registry.others = {};
|
||||
registry.download.mockReset();
|
||||
});
|
||||
|
||||
@@ -856,3 +867,220 @@ describe("release notes", () => {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: new Map<string, string>(),
|
||||
plugins: [] as Array<{ id: string; registryId: string | null }>,
|
||||
indexes: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) =>
|
||||
void state.settings.set(key, value),
|
||||
}),
|
||||
createCurrentPluginRepository: () => ({
|
||||
listAll: async () => state.plugins,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../plugins/registry-index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchIndexAt: async (url: string) => {
|
||||
const raw = state.indexes.get(url);
|
||||
if (!raw) throw new Error("answered 404");
|
||||
return actual.parseRegistryIndex(raw);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const registries = await import("../../plugins/registries.js");
|
||||
const { getCustomPluginKeys, pluginKeyId } =
|
||||
await import("../../plugins/trust.js");
|
||||
|
||||
function newKey(): string {
|
||||
return crypto
|
||||
.generateKeyPairSync("ed25519")
|
||||
.publicKey.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
}
|
||||
|
||||
const URL_A = "https://example.com/a/index.json";
|
||||
|
||||
function serve(url: string, index: Record<string, unknown>): void {
|
||||
state.indexes.set(url, { plugins: [], ...index });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
state.settings.clear();
|
||||
state.plugins = [];
|
||||
state.indexes.clear();
|
||||
await registries.loadCustomRegistryKeys();
|
||||
});
|
||||
|
||||
describe("registry sources", () => {
|
||||
it("is official only by default", async () => {
|
||||
const sources = await registries.listSources();
|
||||
expect(sources.map((source) => source.id)).toEqual(["official"]);
|
||||
});
|
||||
|
||||
it("adds community when it is turned on", async () => {
|
||||
await registries.setRegistryEnabled("community", true);
|
||||
const sources = await registries.listSources();
|
||||
expect(sources.map((source) => [source.id, source.tier])).toEqual([
|
||||
["official", "official"],
|
||||
["community", "community"],
|
||||
]);
|
||||
await registries.setRegistryEnabled("community", false);
|
||||
expect((await registries.listSources()).map((s) => s.id)).toEqual([
|
||||
"official",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never turns the official registry off", async () => {
|
||||
await expect(
|
||||
registries.setRegistryEnabled("official", false),
|
||||
).rejects.toMatchObject({ code: "OFFICIAL" });
|
||||
});
|
||||
|
||||
it("reads a broken stored value as official only", () => {
|
||||
expect(registries.parseRegistriesConfig("{nope")).toEqual({
|
||||
community: { enabled: false },
|
||||
custom: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding a custom registry", () => {
|
||||
it("previews the name, plugin count and key, then pins that key", async () => {
|
||||
const key = newKey();
|
||||
serve(URL_A, {
|
||||
registry: "acme",
|
||||
name: "Acme Plugins",
|
||||
publicKey: key,
|
||||
plugins: [
|
||||
{
|
||||
id: "tool",
|
||||
versions: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
api: "1",
|
||||
url: "https://example.com/tool.tmxplug",
|
||||
sha256: "a".repeat(64),
|
||||
signature: "sig",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const preview = await registries.previewRegistry(URL_A);
|
||||
expect(preview).toEqual({
|
||||
id: "acme",
|
||||
name: "Acme Plugins",
|
||||
url: URL_A,
|
||||
keyId: pluginKeyId(key),
|
||||
plugins: 1,
|
||||
});
|
||||
|
||||
const added = await registries.addRegistry(URL_A, preview.keyId);
|
||||
expect(added).toMatchObject({ id: "acme", enabled: true, publicKey: key });
|
||||
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||
const sources = await registries.listSources();
|
||||
expect(sources.at(-1)).toMatchObject({
|
||||
id: "acme",
|
||||
tier: "custom",
|
||||
url: URL_A,
|
||||
});
|
||||
expect(sources.at(-1)?.keys.map((k) => k.publicKey)).toEqual([key]);
|
||||
});
|
||||
|
||||
it("refuses when the key changed after the preview", async () => {
|
||||
serve(URL_A, { registry: "acme", publicKey: newKey() });
|
||||
const preview = await registries.previewRegistry(URL_A);
|
||||
serve(URL_A, { registry: "acme", publicKey: newKey() });
|
||||
await expect(
|
||||
registries.addRegistry(URL_A, preview.keyId),
|
||||
).rejects.toMatchObject({ code: "KEY_MISMATCH" });
|
||||
expect((await registries.readRegistriesConfig()).custom).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an index without a key", async () => {
|
||||
serve(URL_A, { registry: "acme" });
|
||||
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
|
||||
code: "NO_PUBLIC_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses plain http and junk", async () => {
|
||||
await expect(
|
||||
registries.previewRegistry("http://example.com/index.json"),
|
||||
).rejects.toMatchObject({ code: "INVALID_URL" });
|
||||
await expect(registries.previewRegistry("nope")).rejects.toMatchObject({
|
||||
code: "INVALID_URL",
|
||||
});
|
||||
await expect(registries.previewRegistry(42)).rejects.toMatchObject({
|
||||
code: "INVALID_URL",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an index it cannot read", async () => {
|
||||
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
|
||||
code: "REGISTRY_UNREACHABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses the same URL twice and the built in ones", async () => {
|
||||
const key = newKey();
|
||||
serve(URL_A, { registry: "acme", publicKey: key });
|
||||
await registries.addRegistry(URL_A, pluginKeyId(key));
|
||||
await expect(registries.previewRegistry(URL_A)).rejects.toMatchObject({
|
||||
code: "ALREADY_ADDED",
|
||||
});
|
||||
await expect(
|
||||
registries.previewRegistry(
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/index.json",
|
||||
),
|
||||
).rejects.toMatchObject({ code: "ALREADY_ADDED" });
|
||||
});
|
||||
|
||||
it("never takes a reserved or used id", async () => {
|
||||
const urlB = "https://example.com/b/index.json";
|
||||
const urlC = "https://example.com/c/index.json";
|
||||
const keyA = newKey();
|
||||
const keyB = newKey();
|
||||
const keyC = newKey();
|
||||
serve(URL_A, { registry: "official", publicKey: keyA });
|
||||
serve(urlB, { registry: "community", publicKey: keyB });
|
||||
serve(urlC, { registry: "Official", publicKey: keyC });
|
||||
expect((await registries.addRegistry(URL_A, pluginKeyId(keyA))).id).toBe(
|
||||
"official-2",
|
||||
);
|
||||
expect((await registries.addRegistry(urlB, pluginKeyId(keyB))).id).toBe(
|
||||
"community-2",
|
||||
);
|
||||
expect((await registries.addRegistry(urlC, pluginKeyId(keyC))).id).toBe(
|
||||
"official-3",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("turning off and removing a custom registry", () => {
|
||||
async function addAcme(): Promise<string> {
|
||||
const key = newKey();
|
||||
serve(URL_A, { registry: "acme", publicKey: key });
|
||||
await registries.addRegistry(URL_A, pluginKeyId(key));
|
||||
return key;
|
||||
}
|
||||
|
||||
it("keeps trusting the key of a registry that is off", async () => {
|
||||
const key = await addAcme();
|
||||
await registries.setRegistryEnabled("acme", false);
|
||||
expect((await registries.listSources()).map((s) => s.id)).toEqual([
|
||||
"official",
|
||||
]);
|
||||
expect(await registries.findSource("acme")).toBeNull();
|
||||
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||
});
|
||||
|
||||
it("refuses to remove a registry with installed plugins", async () => {
|
||||
await addAcme();
|
||||
state.plugins = [{ id: "tool", registryId: "acme" }];
|
||||
await expect(registries.removeRegistry("acme")).rejects.toMatchObject({
|
||||
code: "REGISTRY_IN_USE",
|
||||
details: { plugins: ["tool"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes it and stops trusting its key", async () => {
|
||||
await addAcme();
|
||||
await registries.removeRegistry("acme");
|
||||
expect((await registries.readRegistriesConfig()).custom).toEqual([]);
|
||||
expect(getCustomPluginKeys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("only turns built in registries off", async () => {
|
||||
await expect(registries.removeRegistry("community")).rejects.toMatchObject({
|
||||
code: "BUILT_IN",
|
||||
});
|
||||
await expect(registries.removeRegistry("nope")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("keys from a linked server", () => {
|
||||
it("trusts them next to the local ones and survives a reload", async () => {
|
||||
const key = newKey();
|
||||
await registries.setSyncedRegistryKeys([key, "junk", 42]);
|
||||
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||
await registries.loadCustomRegistryKeys();
|
||||
expect(getCustomPluginKeys().map((k) => k.publicKey)).toEqual([key]);
|
||||
await registries.setSyncedRegistryKeys([]);
|
||||
expect(getCustomPluginKeys()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,14 +17,27 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
||||
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,
|
||||
]),
|
||||
// The built in official keys are swapped for the test key. Keys a
|
||||
// caller passes for another registry are used as they are.
|
||||
verifyPluginArtifact: (
|
||||
buffer: Buffer,
|
||||
sha256: string,
|
||||
signature: string,
|
||||
keys?: readonly unknown[],
|
||||
) =>
|
||||
actual.verifyPluginArtifact(
|
||||
buffer,
|
||||
sha256,
|
||||
signature,
|
||||
keys && keys !== actual.TRUSTED_PLUGIN_KEYS
|
||||
? (keys as never)
|
||||
: [testKey.value as never],
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
communitySource,
|
||||
downloadRelease,
|
||||
fetchRegistryIndex,
|
||||
fetchRegistryStats,
|
||||
@@ -35,6 +48,7 @@ import {
|
||||
parseRegistryIndex,
|
||||
parseRegistryStats,
|
||||
resetRegistryCache,
|
||||
type RegistrySource,
|
||||
type RegistryVersion,
|
||||
} from "../../plugins/registry-index.js";
|
||||
|
||||
@@ -91,6 +105,25 @@ afterEach(() => {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("parseRegistryIndex publicKey", () => {
|
||||
const key = publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
|
||||
it("keeps a valid key and drops anything else", () => {
|
||||
expect(parseRegistryIndex({ publicKey: key, plugins: [] }).publicKey).toBe(
|
||||
key,
|
||||
);
|
||||
expect(
|
||||
parseRegistryIndex({ publicKey: "not a key", plugins: [] }).publicKey,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseRegistryIndex({ publicKey: 42, plugins: [] }).publicKey,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRegistryIndex", () => {
|
||||
it("keeps valid entries and sorts versions newest first", () => {
|
||||
const index = parseRegistryIndex({
|
||||
@@ -289,7 +322,29 @@ describe("fetchRegistryIndex", () => {
|
||||
fetchMock.mockResolvedValueOnce(response(body));
|
||||
const first = await fetchRegistryIndex();
|
||||
fetchMock.mockResolvedValueOnce(response("down", 503));
|
||||
expect(await fetchRegistryIndex({ force: true })).toBe(first);
|
||||
expect(await fetchRegistryIndex(undefined, { force: true })).toBe(first);
|
||||
});
|
||||
|
||||
it("caches each registry on its own", async () => {
|
||||
const other: RegistrySource = {
|
||||
id: "mine",
|
||||
name: "Mine",
|
||||
url: "https://example.com/mine/index.json",
|
||||
tier: "custom",
|
||||
keys: [],
|
||||
};
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response(body))
|
||||
.mockResolvedValueOnce(
|
||||
response(JSON.stringify({ registry: "mine", plugins: [] })),
|
||||
);
|
||||
const official = await fetchRegistryIndex();
|
||||
const mine = await fetchRegistryIndex(other);
|
||||
expect(official.plugins).toHaveLength(1);
|
||||
expect(mine.plugins).toHaveLength(0);
|
||||
expect(fetchMock.mock.calls[1][0]).toBe(other.url);
|
||||
expect(await fetchRegistryIndex(other)).toBe(mine);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws when there is nothing cached", async () => {
|
||||
@@ -384,6 +439,38 @@ describe("downloadRelease", () => {
|
||||
).rejects.toThrow(/sha256/);
|
||||
});
|
||||
|
||||
it("checks against the keys of the registry it came from", async () => {
|
||||
const signed = sign(artifact);
|
||||
const own = crypto.generateKeyPairSync("ed25519");
|
||||
const ownKey = {
|
||||
id: "own",
|
||||
publicKey: own.publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64"),
|
||||
addedIn: "admin",
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
await expect(
|
||||
downloadRelease("docker", version({ ...signed, size: artifact.length }), [
|
||||
ownKey,
|
||||
]),
|
||||
).rejects.toThrow(/trusted key/);
|
||||
|
||||
const digest = crypto.createHash("sha256").update(artifact).digest();
|
||||
fetchMock.mockResolvedValueOnce(response(artifact));
|
||||
const result = await downloadRelease(
|
||||
"docker",
|
||||
version({
|
||||
sha256: digest.toString("hex"),
|
||||
signature: crypto.sign(null, digest, own.privateKey).toString("base64"),
|
||||
size: artifact.length,
|
||||
}),
|
||||
[ownKey],
|
||||
);
|
||||
expect(fs.readFileSync(result.file)).toEqual(artifact);
|
||||
});
|
||||
|
||||
it("refuses a release built for another plugin API", async () => {
|
||||
await expect(
|
||||
downloadRelease("docker", version({ api: "2" })),
|
||||
@@ -407,19 +494,34 @@ describe("registry stats", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("has a community stats file and none for custom registries", () => {
|
||||
expect(getStatsUrl(communitySource())).toBe(
|
||||
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/community/stats.json",
|
||||
);
|
||||
expect(
|
||||
getStatsUrl({
|
||||
id: "mine",
|
||||
name: "Mine",
|
||||
url: "https://example.com/index.json",
|
||||
tier: "custom",
|
||||
keys: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps valid counts and drops the rest", () => {
|
||||
const stats = parseRegistryStats({
|
||||
plugins: {
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
docker: { downloads: 120 },
|
||||
tunnels: { downloads: 7 },
|
||||
"../bad": { downloads: 1 },
|
||||
broken: { downloads: -3, activeInstalls: "many" },
|
||||
broken: { downloads: -3 },
|
||||
},
|
||||
});
|
||||
expect(Object.fromEntries(stats.plugins)).toEqual({
|
||||
docker: { downloads: 120, activeInstalls: 40 },
|
||||
tunnels: { downloads: 7, activeInstalls: null },
|
||||
broken: { downloads: 0, activeInstalls: null },
|
||||
docker: 120,
|
||||
tunnels: 7,
|
||||
broken: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,9 +530,6 @@ describe("registry stats", () => {
|
||||
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
||||
);
|
||||
const stats = await fetchRegistryStats();
|
||||
expect(stats?.plugins.get("docker")).toEqual({
|
||||
downloads: 3,
|
||||
activeInstalls: null,
|
||||
});
|
||||
expect(stats?.plugins.get("docker")).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { PluginLoader } from "../../plugins/loader.js";
|
||||
const testKey = vi.hoisted(() => ({
|
||||
privateKey: null as crypto.KeyObject | null,
|
||||
key: { id: "test", publicKey: "", addedIn: "2.9.0" },
|
||||
communityPrivateKey: null as crypto.KeyObject | null,
|
||||
community: { id: "community", publicKey: "", addedIn: "26.10.1" },
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/trust.js", async (importActual) => {
|
||||
@@ -23,6 +25,17 @@ vi.mock("../../plugins/trust.js", async (importActual) => {
|
||||
...actual,
|
||||
verifyPluginArtifact: (buffer: Buffer, sha256: string, signature: string) =>
|
||||
actual.verifyPluginArtifact(buffer, sha256, signature, [testKey.key]),
|
||||
verifyAnyTrusted: (buffer: Buffer, sha256: string, signature: string) => {
|
||||
const official = actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.key,
|
||||
]);
|
||||
if (official.ok === true) return { ...official, tier: "official" };
|
||||
const community = actual.verifyPluginArtifact(buffer, sha256, signature, [
|
||||
testKey.community,
|
||||
]);
|
||||
if (community.ok === true) return { ...community, tier: "community" };
|
||||
return community;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -42,6 +55,12 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
const community = crypto.generateKeyPairSync("ed25519");
|
||||
testKey.communityPrivateKey = community.privateKey;
|
||||
testKey.community.publicKey = community.publicKey
|
||||
.export({ format: "der", type: "spki" })
|
||||
.subarray(12)
|
||||
.toString("base64");
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
@@ -68,7 +87,7 @@ function setup() {
|
||||
async function writeArtifact(
|
||||
dir: string,
|
||||
id: string,
|
||||
sig: "valid" | "wrong-key" | "none",
|
||||
sig: "valid" | "community" | "wrong-key" | "none",
|
||||
version = "1.0.0",
|
||||
): Promise<string> {
|
||||
const build = tempRoot("termix-build-");
|
||||
@@ -82,7 +101,9 @@ async function writeArtifact(
|
||||
const signer =
|
||||
sig === "valid"
|
||||
? testKey.privateKey
|
||||
: crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
: sig === "community"
|
||||
? testKey.communityPrivateKey
|
||||
: crypto.generateKeyPairSync("ed25519").privateKey;
|
||||
const digest = crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(file))
|
||||
@@ -171,6 +192,27 @@ describe("updating a bundled plugin", () => {
|
||||
expect(plugin?.source).toBe("bundled");
|
||||
expect(plugin?.artifact).toBe(file);
|
||||
expect(plugin?.signedBy).toBe("test");
|
||||
expect(plugin?.signedTier).toBe("official");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for a community signed newer archive", async () => {
|
||||
const { userDir } = setup();
|
||||
await writeArtifact(userDir, "bundled-one", "community", "2.0.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("loads a community signed plugin and records its tier", async () => {
|
||||
const { userDir } = setup();
|
||||
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
|
||||
await writeArtifact(userDir, "community-one", "community");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
const plugin = loader.get("community-one");
|
||||
expect(plugin?.source).toBe("user");
|
||||
expect(plugin?.signedBy).toBe("community");
|
||||
expect(plugin?.signedTier).toBe("community");
|
||||
});
|
||||
|
||||
it("keeps the bundled copy for an unsigned newer archive", async () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
COMMUNITY_PLUGIN_KEYS,
|
||||
TRUSTED_PLUGIN_KEYS,
|
||||
isPluginPublicKey,
|
||||
pluginKeyId,
|
||||
requireSignedPlugins,
|
||||
setCustomPluginKeys,
|
||||
verifyAnyTrusted,
|
||||
verifyPluginArtifact,
|
||||
type TrustedPluginKey,
|
||||
} from "../../plugins/trust.js";
|
||||
@@ -97,6 +103,61 @@ describe("verifyPluginArtifact", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("key tiers", () => {
|
||||
const artifact = Buffer.from("a plugin archive");
|
||||
|
||||
it("derives key ids the same way the built in keys were made", () => {
|
||||
for (const key of [...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS]) {
|
||||
expect(pluginKeyId(key.publicKey)).toBe(key.id);
|
||||
expect(isPluginPublicKey(key.publicKey)).toBe(true);
|
||||
}
|
||||
expect(isPluginPublicKey("bm9wZQ==")).toBe(false);
|
||||
expect(isPluginPublicKey(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the community key apart from the official ones", () => {
|
||||
const official = new Set(TRUSTED_PLUGIN_KEYS.map((key) => key.id));
|
||||
for (const key of COMMUNITY_PLUGIN_KEYS) {
|
||||
expect(official.has(key.id)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies a custom key only while it is set, as the custom tier", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||
|
||||
setCustomPluginKeys([key]);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature)).toEqual({
|
||||
ok: true,
|
||||
keyId: "test",
|
||||
tier: "custom",
|
||||
});
|
||||
|
||||
setCustomPluginKeys([]);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a custom key that is not a valid public key", () => {
|
||||
setCustomPluginKeys([{ id: "bad", publicKey: "bm9wZQ==", addedIn: "x" }]);
|
||||
const { privateKey } = keyPair();
|
||||
const { sha256, signature } = signed(artifact, privateKey);
|
||||
expect(verifyAnyTrusted(artifact, sha256, signature).ok).toBe(false);
|
||||
setCustomPluginKeys([]);
|
||||
});
|
||||
|
||||
it("stops at a sha256 mismatch", () => {
|
||||
const { key, privateKey } = keyPair();
|
||||
setCustomPluginKeys([key]);
|
||||
const { signature } = signed(artifact, privateKey);
|
||||
expect(verifyAnyTrusted(artifact, "0".repeat(64), signature)).toEqual({
|
||||
ok: false,
|
||||
reason: "sha256 does not match the file",
|
||||
});
|
||||
setCustomPluginKeys([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireSignedPlugins", () => {
|
||||
it("is on only for the exact value true", () => {
|
||||
const before = process.env.TERMIX_REQUIRE_SIGNED_PLUGINS;
|
||||
|
||||
Reference in new issue
Block a user